Ejemplo con Weka

De ChuWiki
Saltar a: navegación, buscar

Weka es una librería/aplicación java con algoritmos ya implementados para clasificación de datos, creación de clusters ... en definitiva, data mining y machine learning por usar palabros de moda.

Aquí tienes el código java completo de ejemplos con weka

Algunos conceptos

La idea de estos algoritmos es la siguiente. Tenemos un conjunto de muestras (por ejemplo, varios tipos de frutas) y cada muestra tiene un conjunto de valores que podamos representar de alguna forma (tamaño, forma, color, ...). Estos valores pueden ser los evidentes, pero es toda una ingeniería encontrar y elegir más valores no evidentes, por ejemplo, podemos poner el volumen de la fruta, su densidad, o la raíz cuadrada de la longitud del rabito. Con estas muestras entrenamos al algoritmo de forma que sea capaz de concretar que valores son los que son representativo de cada muestra (por ejemplo, las manzanas son redondas y los plátanos alargados).

Una vez que ha aprendido, si le damos un nuevo conjunto de valores, será capaz de deducir a qué tipo de muestra corresponde (si es plátano o pera).

Aprendizaje Supervisado y sin Supervisar

En un aprendizaje supervisado, sabemos exactamente de qué tipo es la muestra y se lo decimos al algoritmo. Le decimos "esta manzana mide 6 cm es redonda y verde" y luego "esta manzana mide 8 cm y es roja" y así sucesivamente. Y le decimos que manzana, plátano, pera ... es el tipo de muestra y lo que luego queremos saber cuando le demos los otros valores, que nos diga qué tipo de fruta es. El aprendizaje es supervisado porque luego veremos si nos da los resultados correctos y si no lo hace, volveremos a probar otra forma de entrenamiento u otro algoritmo.

En un aprendizaje sin supervisar no sabemos de qué tipo es cada muestra. Sólo sabemos valores y queremos que el algoritmo busque si hay algún tipo de patrón oculto. El algoritmo deducirá que hay frutas alargadas y amarillas, pero no sabrá que son plátanos.

Arboles de decisión

En el aprendizaje supervisado se usan, entre otros algoritmos, árboles de decisión. Le damos los valores de cada muestra y de qué tipo es. El algoritmo empieza a buscar los "umbrales" óptimos para hacer comparaciones y poder clasificar los tipos que le han pasado. Por ejemplo, si le pasamos varios ejemplos de uva que miden alrededor de 1 cm y le damos varios tipos de manzanas que miden alrededor de 6 cm, el algoritmo puede decidir que si compara tamaño con 3.5 cm puede saber fácilmente si es uva o manzana. Esto es un ejemplo muy evidente, pero los algoritmos existentes, aunque no son mágicos, son capaces de encontrar casos más complejos que no nos resultan evidentes a los humanos.

Una vez entrenado el algoritmo (ha encontrado los umbrales que considera óptimos), si le damos una fruta de 2 cm, será capaz de aplicar las comparaciones que haya determinado y decirnos que es una uva.

Clustering

Si no es supervisado y no le decimos el tipo, el algoritmo hará grupos con los datos, basándose en la similitud de los grupos de valores. Dirá que hay un grupo de frutas de alrededor de 1 cm y que son redondas y verdes (uvas), otro de 1 cm, redondas y oscuras (uvas negras), etc, etc.

Cuando le pasemos un nuevo conjunto de valores, dirá a qué grupo pertenece.

Añadiendo Weka a nuestro proyecto java

Dicho esto, vamos a jugar con ello en java. Usaremos alguna librería que nos ahorre los cálculos complejos y nos facilite la vida. La librería Weka está en el repositorio maven, así que basta poner la dependencia maven o gradle en nuestro proyecto

dependencies {
   compile group: 'nz.ac.waikato.cms.weka', name: 'weka-stable', version: '3.8.2'
}

Crear conjunto de datos de test

Weka admite un fichero específico de datos en formato arff, pero aquí vamos a construir el conjunto de datos con código java

    public static String[] types = {"patera","lancha","barco"};
    public static Instances getData() throws Exception {
        ArrayList<Attribute> atts = new ArrayList<>();
        Instances       data;
        double[]        vals;


        // Atributos, diciendo nombre y posibles valores. Si no se indica
        // nada, son numéricos.
        atts.add(new Attribute("type", Arrays.asList(types)));
        atts.add(new Attribute("size"));
        atts.add(new Attribute("vmax"));
        atts.add(new Attribute("vmin"));
        atts.add(new Attribute("mmsi", Arrays.asList("si","no")));

        // Creamos el conjunto de datos, de momento vacío, indicando qué atributos
        // van a llevar.
        data = new Instances("MyData", atts, 0);

        // Rellenamos datos.
        // pateras
        for (int i=0;i<10;i++) {
            vals = getPatera(data.numAttributes());
            data.add(new DenseInstance(1.0,vals));
        }

        // lanchas
        for (int i=0;i<10;i++) {
            vals = getLancha(data.numAttributes());
            data.add(new DenseInstance(1.0,vals));
        }

        // barco
        for (int i=0;i<10;i++) {
            vals = getBarco(data.numAttributes());
            data.add(new DenseInstance(1.0,vals));
        }

        data.setClassIndex(0);
        // 4. output data
//        System.out.println(data);

        return data;
    }

Instances es la clase Java de Weka donde van a ir los datos. Así que la creamos. Pero para crearla, debemos decir qué tipos de valores pueden llevar la muestra, así que primero creamos un ArrayList<Attribute>. Cada Attribute es uno de los valores que nos interesan y debemos decir de qué tipo es. En este ejemplo, vamos a querer clasificar barcos en barcos normales, pateras y lanchas rápidas. Para cada uno de estos tipos de barcos elegimos los siguientes valores:

  • El tipo de barco (normal, lancha, patera), que es lo que luego querremos saber
  • El tamaño del barco (los normales serán grandes, las lanchas y pateras pequeñas)
  • Velocidad máxima que hemos medido en el barco (las pateras lentas, las lanchas rápidas y los barcos normales una velocidad media)
  • Velocidad mínima que hemos medido en el barco (todos lentos cuando van despacio o se paran)
  • Si tiene o no MMSI (identificación del servicio de movil marítmo). Los barcos "serios" lo tienen y las pateras y lanchas rápidas no suelen.

Así que vamos creando instancias de Attribute, dando el nombre que queramos al Attribute (type, size, vmax, vmin, mmsi) y los vamos añadiendo al ArrayList de atributos. Si al crear el Attribute no decimos nada, será numérico por defecto. Si pasamos como segundo parámetro una lista de String, será un "enumerado" cuyos valores son los String. Es el caso de type, que puede ser "patera","lancha","barco", o el de mmsi, que puede ser "si", "no".

Ya podemos crear una instancia de Instances, que son los datos, pasando en el constructor un nombre de nuestra elección para este conjunto de datos, la lista de atributos, y la capacidad inicial que queremos que tenga (0 en el código) data = new Instances("MyData", atts, 0);

Ahora vamos añadiendo pateras, lanchas y barcos, en el ejemplo, 10 de cada. Para crear muestras concretas necesitamos instanciar la clase Instance de Weka e ir añadiendola a Instances. Como Instance es abstracta, debemos instanciar alguna de sus clases hijas. Hay DenseInstance y SparseInstance. La primera es cuando la muestra tiene todos o la mayoría de los valores de sus atributos. Una SparseInstance es cuando muchos de estos valores son desconocidos y no queremos rellenarlos. En el primer caso, como se rellenan todos, basta dar un array de valores con los valores rellenos. En el segundo caso, deberíamos ir uno a uno indicando qué atributo es y que valor tiene. Vamos con el DenseInstance.

Para cada muestra, hay que crear un array de double[] con cinco valores, cada uno es uno de los atributos. Para los enumerados, el valor es el indice del enumerado que queramos usar. Por ejemplo, 0 para patera, 1 para lancha y 2 para barco.

Los métodos getPatera(), getLancha() y getBarco() sólo nos devuelven el array relleno con valores aleatorios, pero más o menos en el rango que hemos decidido para cada tipo de embarcación. Ponemos uno de ellos nada más

    public static double[] getBarco(int numAttributes) {
        double[] vals;
        vals = new double[numAttributes];
        vals[0]=2; // barco, el indice 2 del enumerado patera, lancha, barco.
        vals[1]=Math.random()*30+50; // grande, entre 50 m y 80 m
        vals[2]=Math.random()*5+25; // vmax media, entre 25 nudos y 30 nudos
        vals[3]=Math.random()*2+5; // vmin lenta, entre 5 y 7 nudos.
        vals[4]=0; // con mmsi     // tiene mmsi.
        return vals;
    }

Y con esto creamos todos nuestros datos de muestra. Sólo nos falta decir cual de los atributos es el que clasifica a cada muestra (lancha, patera, barco). Es el de indice cero, así que hacemos la llamada data.setClassIndex(0);. Este atributo indica la "clase" o tipo de muestra que es.

Entrentar el árbol

Entrenar el árbol es fácil. Hay en Weka varias clases que son árboles de decisión siguiendo distintos algortimos. Están bajo el paquete weka.classifiers.trees. Usaremos J48, por ningún motivo especial.

        Classifier j48 = new J48();
        Instances trainingData = GenerateTestVessels.getData();
        j48.buildClassifier(trainingData);
        System.out.println(j48);

Simplemente instanciamos la clase, llamamos a su método buildClassifier() pasandole los datos de entrenamiento, obtenidos con el método que explicamos antes y ya está el árbol entrenado. Si lo sacamos por pantalla como si fuera un String, obtenemos una salida como la siguiente

mmsi = si: barco (10.0)
mmsi = no
|   vmax <= 11.949105: patera (10.0)
|   vmax > 11.949105: lancha (10.0)

Number of Leaves  : 	3

Size of the tree : 	5

Que en nuestro ejemplo significa algo así como

  • Primero mira si tiene mmsi. Si lo tiene es barco (hay 10 muestras así en los datos de entrenamiento)
  • Si no tiene mmsi, ha decidido que compara si la velocidad máxima es menor o igual que 11.949105. En ese caso es patera y hay 10 muestras en los datos de entrenamiento.
  • Finalmente, si la velocidad máxima es mayor que 11.949105 es una lancha y hay 10 muestras en los datos de entrenamiento.

El árbol tiene 3 hojas finales (barco, patera y lancha) y un total de 5 nodos (las 3 hojas y dos nodos de decisión, si tiene mmsi y si la velocidad máxima es mayor de 11.949105.

Clasificar una nueva muestra

Ya tenemos el árbol entrenado. Vamos a darle una nueva muestra que no sepamos de qué tipo es y lo probamos. El código puede ser así

        double[] vesselUnderTest = GenerateTestVessels.getBarco(5);

        DenseInstance inst = new DenseInstance(1.0,vesselUnderTest);
        inst.setDataset(trainingData);
        inst.setClassMissing();
        System.out.println(inst);

        double result = j48.classifyInstance(inst);
        System.out.println(GenerateTestVessels.types[(int)result]);

Creamos con el método adecuado un nuevo barco, que tiene 5 atributos. Este método nos devuelve como vimos antes un array de 5 doubles, con los valores que dijimos de tipo de barco, tamaño, velocidad máxima y mínima y si tiene o no mmsi.

Con este array creamos una DenseInstance, igual que antes, pero tenemos que hacerle dos cosas:

  • Pasarle una referencia a los datos de entrenamiento.
  • Decirle que ignore el valor que identifica el tipo de barco inst.setClassMissing(), de forma que vacíe el campo type de los doubles.

Si sacamos por pantalla la instancia podemos ver algo como esto

?,50.294706,27.495384,5.977292,si

El primer interrogante es el tipo de embarcación, desconocida al haber llamado a setClassMissing(). Los demás son los valores de tamaño, velocidad máxima, velocidad mínima y sí tiene mmsi.

Ahora sólo nos queda llamar a j48.classifyInstance() para que el árbol nos indique de qué tipo cree que es el barco. Lo devolverá como double con el criterio que dijimos en el enumerado en su momento

public static String[] types = {"patera","lancha","barco"};

es decir, 0 para patera, 1 para lancha y 2 para barco.

Clustering

A veces tenemos muestras y no sabemos clasificarlas de antemano, si son aleatorias o hay patrones ocultos. El Clustering es otro tipo de algoritmo al que damos nuestros datos de entrenamiento sin indicar en ningún momento de qué tipo son las muestras. Ninguno de los valores, como hicimos con el árbol de entrenamiento, se marca como clase o tipo de la muestra.

Vamos a coger nuestros barcos y quitamos el atributo de tipo, dejando los otros cuatro. Cogemos algún algoritmo de Cluster de Weka y a ver qué nos dice. El código de ejemplo es este

        Instances data = GenerateTestVessels.getData();
        data.setClassIndex(-1); // No class index.

        Remove rm = new Remove();
        rm.setAttributeIndices("1");
        rm.setInputFormat(data);
        data = Filter.useFilter(data,rm);
        System.out.println(data);


        EM cw = new EM();

        cw.buildClusterer(data);
        System.out.println(cw);

Generamos los mismos datos que antes.

El Cluster no tiene ningún atributo que identifique de que tipo/clase es la muestra, así que llamamos a setClassIndex(-1) para eliminar el que nos puso el método getData().

Weka tiene filtros que permiten jugar con los datos. En concreto, nos interesa ahora un filtro que coja los datos y les elimine el atributo de tipo de barco. Creamos una instancia de Remove, le decimos que el índice del tipo de dato que queremos quitar es el primero (el del tipo). Aquí hay un detalle curioso. Weka piensa que la entrada de este parámetro de índices de atributos a eliminar lo va a meter un usuario a mano, así que el índice empieza en 1 (en vez de en 0) y es un String que admite "rangos", separando con guiones, indices sueltos separando con comas, e incluso palabras mágicas como "first" y "last". Así, por ejemplo, una entrada válida para este parámetro sería "first-3,5,6-last", que eliminaría desde el 1 al 3, el 5 y desde el 6 hasta el final.

Le decimos a remove el formato de los datos que le vamos a pasar ... pasándole los datos para que los vea setInputFormat(data)

Y ahora llamamos a Filter.userFilter() para filtrar, pasando como parámetro los datos y el filtro. Nos devolverá los mismos datos, pero sin el atributo de tipo que los identifica.

Elegimos un algoritmo de cluster dentro del paquete weka.clusterers. En este caso, EM básicamente porque es el primero que he encontrado un ejemplo en internet cuando he buscado. Lo instanciamos y lo entrenamos con cw.buildClusterer(data).

Listo. Si lo sacamos por pantalla obtenemos lo siguiente

EM
==

Number of clusters selected by cross validation: 3
Number of iterations performed: 2


            Cluster
Attribute         0       1       2
             (0.33)  (0.33)  (0.33)
====================================
size
  mean        15.659 57.0745 15.1591
  std. dev.    2.728  5.0747   3.037

vmax
  mean       11.0504 27.2912 40.7139
  std. dev.   0.5267  1.3752  5.1847

vmin
  mean        5.9683  5.8936  5.6701
  std. dev.   0.5877   0.463  0.4685

mmsi
  si               1      11       1
  no              11       1      11
  [total]         12      12      12

Básicamente, ha decidido que hay tres cluster (o tres grupos de datos). Los ha llamado 0, 1 y 2 y cada uno de ellos tiene el 33% de las muestras (0.33). En cada cluster, indica el tamaño medio de cada barco (size), su velocidad maxima media (vmax) y su velocidad mínima media (vmin), y si tiene o no mmsi (el número 1, 11 y 12 es totalmente misterioso para mí).

A partir de aquí, podemos meter datos nuevos y el cluster nos dirá a cual de los grupos cree que pertenece el nuevo dato. Así, de forma rápida

<syntaxhihglight lang="java"> System.out.println(cw.clusterInstance(data.firstInstance())); </syntaxhighlight>

Cogemos el primer dato de las muestras y llamamos a <coe>cw.clusterIntance()</code> que nos dirá a qué cluster pertence. Sale el 2, que es el cluster de los pequeños y lentos, o sea, patera. Este 2 no tiene nada que ver con el índice que nosotros dimos a cada uno de nuestros tipos, ya que los cluster 0, 1 y 2 no corresponden a los tipos 0, 1 y 2, sino a lo que el algoritmo haya tenido a bien agrupar.

Salvar y Cargar el modelo Weka

Una vez que tenemos entrenado cualquier algoritmo, es bueno guardarlo en un fichero y luego poder cargarlo si queremos volver a usarlo, sin necesidad de tener todos los datos y entrenar cada vez. Weka nos ofrece la clase SerializationHelper para salvar y cargar el algoritmo entrenado. El método write() lo salva en fichero sin más que dar el fichero y el algorítmo. Y el método read() nos permite leerlo. Eso sí, lo obtenemos como Object, así que tenemos que hacer el cast, lo que implica que tenemos que saber de qué tipo es cuando lo carguemos.

        SerializationHelper.write(new FileOutputStream("tmp"), j48);
        J48 j48Read = (J48)SerializationHelper.read(new FileInputStream("tmp"));