Wir beginnen mit einem leeren Java-Projekt. Für die Klassifikation werden wir eine Support Vector Machine (SVM) aus dem Smile framework benutzen.
Dafür laden wir zunächst die smile-core Komponenten herunter. Dies kann man ganz einfach über maven machen:

<dependencies>
    <dependency>
        <groupId>com.github.haifengl</groupId>
        <artifactId>smile-core</artifactId>
        <version>1.2.0</version>
    </dependency>
</dependencies>

Wenn man maven nicht mag, kann man sich die jars auch einzeln besorgen. Wir werden folgende jars benötigen:

Trainingsdaten

Als Trainingsdaten nehmen wir das wine-Datenset aus dem UCI Machine Learning Repository: Wine-Daten. Dieses Datenset enthält Informationen von Weinen, die alle in der gleichen Region in Italien gewachsen sind, jedoch von drei verschiedenen Rebsorten abstammen. Die Daten liegen im CSV-Format vor und enthalten als ersten Wert die Klasse (sozusagen die Rebsorte) des jeweiligen Beispiels. Die restlichen 13 Werte sind Werte aus einer chemischen Analyse der Weine. Unser Klassifikator soll nun anhand der Features vorhersagen, welche Rebsorte der jeweilige Wein hat.

Einlesen der Daten

Die Klassifikatoren in Smile haben alle ein ähnliches Eingabeformat. Wir benötigen die Klassen als Integer und die Features als double-Array. Da wir (normalerweise) vorher nicht wissen, wie viele Beispiele wir haben, bietet sich hier eine Liste an, die später in ein Array umgewandelt wird:

List<Integer> classes = new ArrayList<>();
List<double[]> features = new ArrayList<>();

Die features unserer Weine lassen sich dann mit etwas Java 8 Syntax recht einfach auslesen:

try (Stream<String> stream = Files.lines(Paths.get("/some/path/to/wine.data"))) {
    stream.forEach((s) -> {
        String[] vals = s.split(",");
        classes.add(Integer.parseInt(vals[0])-1);
        double[] instanceFeatures = new double[vals.length-1];
        for(int i=1; i<vals.length;i++) {
            instanceFeatures[i-1] = Double.parseDouble(vals[i]);
        }
        features.add(instanceFeatures);
    });
}

Nun noch schnell in das gewünschte Input-Format unseres Classifiers umwandeln:

double[][] featureArray = features.stream().toArray(double[][]::new);
int[] classArray = classes.stream().mapToInt(i->i).toArray();

Klassifikation

Die eigentliche Klassifikation geht dann wenn man die Features erst mal hat dank des frameworks recht leicht von der Hand. Da wir direkt eine Cross-Validation durchführen wollen, benötigen wir einen ClassifierTrainer, der die SVMs für die Cross-Validation trainiert:

ClassifierTrainer<double[]> trainer = new SVM.Trainer<>(new GaussianKernel(3), 10, 3, Multiclass.ONE_VS_ALL);

Da unsere Daten drei Klassen enthalten, müssen wir eine MultiClass SVM benutzen. Ich habe mich hier für die Variante one vs all entschieden, da diese schneller klassifiziert (weniger Durchgänge als bei one vs one).
Damit wir bei unserer Cross-Validation auch Ergebnisse sehen, müssen wir ein array aus ClassificationMeasure-Instanzen erstellen. Dieses wird dann an die Cross-Validation Methode als Parameter übergeben:

ClassificationMeasure[] measures = new ClassificationMeasure[]{new Accuracy(), new Precision(), new Recall(), new Fallout(), new FMeasure()};
double[] results = Validation.cv(10, trainer, featureArray, classArray, measures);
for(int i=0;i<results.length;i++) {
    System.out.println(measures[i].getClass().getSimpleName() + ": " + results[i]);
}

Und schon haben wir klassifiziert! Die Ergebnisse sind allerdings recht ernüchternd:

Accuracy: 0.398876404494382
Precision: 0.398876404494382
Recall: 1.0
Fallout: 1.0
FMeasure: 0.570281124497992

Normalisierung

Das liegt daran, dass wir vergessen haben zu normalisieren. SVMs (und auch einige andere Klassifikatoren), erwarten Werte im Intervall [0;1]. Wir normalisieren allerdings nicht hart in dieses Intervall, sondern wählen eine andere Variante der Normalisierung:
Wir ziehen von jedem Feature den Mittelwert ab und teilen dann durch die Standardabweichung. Das Ganze muss per Feature geschehen, über alle Instanzen hinweg. Hier der Code:

//berechnen der Mittelwerte
double[] means = new double[13];

for(double[] fInstance : features) {
    for(int i=0;i<fInstance.length;i++) {
        means[i] += fInstance[i];
    }
}
for(int i=0;i<means.length;i++) {
    means[i] = means[i] / features.size();
}

//berechnen der Varianzen
double[] variances = new double[13];
for(double[] fInstance : features) {
    for(int i=0;i<fInstance.length;i++) {
        variances[i] += Math.pow(fInstance[i]-means[i], 2);
    }
}
for(int i=0;i<variances.length;i++) {
    variances[i] = variances[i] / features.size();
}

//die eigentliche Normalisierung
double[][] featureArray = new double[features.size()][];
for(int i=0;i<features.size();i++) {
    double[] toNormalize = features.get(i);
    double[] normalized = new double[features.get(i).length];
    for(int k=0;k<features.get(i).length;k++) {
        normalized[k] = (toNormalize[k]-means[k])/Math.sqrt(variances[k]);
    }
    featureArray[i] = normalized;
}

Hier gibt es elegantere Möglichkeiten zur Berechnung, im Sinne der Verständlichkeit habe ich hier aber einfach die Schulformeln im Code umgesetzt.

Mit den normalisierten Werten sind die Ergebnisse nun um einiges erbaulicher:

Accuracy: 0.9887640449438202
Precision: 0.9859154929577465
Recall: 0.9859154929577465
Fallout: 0.009345794392523366
FMeasure: 0.9859154929577465

Kernel und Parameter

Damit eine SVM im realen Einsatz gute Ergebnisse liefert, müssen immer der Parameter C und die jeweiligen Parameter im Kernel optimiert werden. Das geschieht meist durch ausprobieren in 10er-Potenzen (also z.B. 0.001->0.01->0.1->1->10->100).
Der meist genutzte Kernel ist hier sicher der Gauß-Kernel, den wir auch oben benutzt haben. Ein linearer Kernel bietet jedoch den Vorteil, dass man leichter (bzw. überhaupt) visualisieren kann was gelernt wurde.

Die optimierte SVM trainieren und ausliefern

Wenn alle Parameter optimiert und wir mit den Ergebnissen zufrieden sind, wird es Zeit die SVM auf allen Trainingsdaten zu trainieren und auszuliefern. Dafür benutzen wir nun direkt die Klasse SVM:

SVM<double[]> svm = new SVM<>(new GaussianKernel(3), 10, 3, Multiclass.ONE_VS_ALL);

Als Parameter nehmen wir hier natürlich die vorher optimierten Werte. Trainiert wird die SVM dann folgendermaßen:

svm.learn(featureArray, classArray);

Dieses Objekt kann man dann serialisieren (z.B. mit xstream oder Gson), und später wieder einlesen.
Wenn man das SVM-Objekt dann erst mal hat, kann man mit ihm Vorhersagen für andere Datensätze treffen. Die Datensätze, für die die Klasse dann vorhergesagt werden soll, müssen vorher auf jeden Fall mit den gleichen Werten normalisiert werden, mit denen auch die Trainingsdaten normalisiert wurden:

double[] newExample = new double[]{12.77,2.39,2.28,19.5,86,1.39,.51,.48,.64,9.899999,.57,1.63,470};
for(int k=0;k<newExample.length;k++) {
    newExample[k] = (newExample[k]-means[k])/Math.sqrt(variances[k]);
}
svm.predict(newExample); //Ergebnis: 2

Die Werte für die Mittelwerte und die Varianzen sollten also gemeinsam mit dem SVM-Objekt serialisiert werden.


Da ich Swing etwas hässlich finde und eclipse RCP Projekte den Hang haben, bei der Auslieferung etwas riesige Dateigrößen anzunehmen, wollte ich mich mit JavaFX beschäftigen.

Lange Zeit war das etwas schwierig, da JavaFX im openjdk von Debian und Ubuntu (zumindest hieß es das, ich habe es damals nicht überprüft) nicht enthalten war. Jetzt hat sich die Lage etwas geändert. Für Debian Jessie gibt es in den Backports sowohl Pakete für openjdk8 als auch für openjfx. Zuerst müssen diese also installiert werden:

echo "deb https://http.debian.net/debian jessie-backports main" >> /etc/apt/sources.list
apt-get update
apt-get -t jessie-backports install openjdk-8-jdk openjfx

Leider ist in dem openjfx Paket der SceneBuilder nicht enthalten und auch Oracle liefert keine binaries mehr aus. Zum Glück gibt es aber andere nette Menschen, die einem die Arbeit abnehmen und den Code compilieren. Bei Gluon kann man sich die Binaries downloaden.

Ich habe mich hier für die executable jar entschieden, da mir der Unterschied von 2,6 MB für die jar zu den 44,9 MB für die .deb doch etwas heftig erschien. Um diese dann mit e(fx)clipse benutzen zu können, muss noch ein kleines wrapper-script angelegt werden:

cp Downloads/SceneBuilder-8.0.0.jar /usr/lib/jvm/java-8-openjdk-amd64/
echo "java -jar /usr/lib/jvm/java-8-openjdk-amd64/SceneBuilder-8.0.0.jar \$1" > /usr/local/bin/fxscenebuilder
chmod +x /usr/local/bin/fxscenebuilder

Damit kann man dann in eclipse in den Einstellungen für die SceneBuilder executable den Pfad "/usr/local/bin/fxscenebuilder" angeben.


Für ein aktuelles Projekt betrachte ich eine Textdatei mit einem "moving window". Ich betrachte also immer einen String mit fester Größe, den ich über die Datei wandern lasse. Auf diesen wende ich dann bestimmte Methoden an, um so Daten zu extrahieren. Meine erste Herangehensweise war, das ganze mit einem StringBuilder zu lösen, also die Datei byte für byte einzulesen und dann jeweils an der ersten Stelle einen char zu löschen. Das ganze sah dann folgendermaßen aus:

StringBuilder sb = new StringBuilder();
FileChannel fc = null;
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
    fc = raf.getChannel();
    long fileSize = fc.size();
    ByteBuffer buf = ByteBuffer.allocate((int) fileSize);
    fc.read(buf);
    buf.flip();
    for (int i = 0; i < fileSize; i+=5) {
        sb.append((char) buf.get());
        if(i>windowSize) {
            sb.delete(0, 1);
        }
        if(i >= windowSize) {
            retVal =  retVal | FileReader.recognizeWithAll(retVal, sb.toString());
        }
    }
} catch (FileNotFoundException e) {
    //...

Diese Lösung hat schon einiges an Performance-Potential, das mir aber auf den ersten Blick nicht aufgefallen beziehungsweise egal war.

Mit dieser Implementierung brauchte mein Rechner für die 24.000 zu bearbeitenden Dateien über eine Stunde. Als mir das zu lang gedauert hat, habe ich einfach parallelisiert und auf 3 Kernen waren es auch nur noch 20 Minuten.

Dann kam allerdings das Problem auf, dass es für manche "Recognizer" praktisch wäre, das Fenster zu vergrößern und nach vorne oder hinten weiter zu suchen, wenn das Ausgangsfenster schon verdächtig schien. Ich habe das Codestück von oben durch dieses ersetzt:

int retVal = 0;
String longString = new String(Files.readAllBytes(Paths.get(file)), Charset.defaultCharset());
int i=0;
if(longString.length() >= windowSize) {
    for(i=0; i<longString.length()-windowSize; i+=5) {
        retVal |= FileReader.recognizeWithAll(retVal, longString, i, i+windowSize);
    }
    if(i!= longString.length()-windowSize) {
        retVal |= FileReader.recognizeWithAll(retVal, longString, longString.length()-windowSize, longString.length());
    }
} else {
    retVal |= FileReader.recognizeWithAll(retVal, longString, i, longString.length());
}

Ich habe also die ganze Datei in einen String eingelesen und dann mit den "Koordinaten" für das Fenster an die Recognizer geschickt. Die Signatur der Recognizer hat sich dabei auch geändert. Die Methoden sahen dann ungefähr so aus:

public int recognize(int retVal, String longString, int start, int end) {
    String recognizeString = longString.substring(start, end);
    //other code...
    return retVal;
}

Nachdem dies implementiert war, dachte ich mir, dass das ja vielleicht auch die Laufzeit kürzer werden müsste, da ich bei dem sb.delete() ein umkopieren des gesamten internen Arrays vermutet habe. Die Ernüchterung kam aber schon beim ersten Durchlauf: Es dauerte ähnlich lang mit dieser Implementierung. Also habe ich mir den StringBuilder-Sourcecode angeschaut. Doch die Vermutung mit dem umkopieren wurde bestätigt:

public AbstractStringBuilder delete(int start, int end) {
    //checks...
    int len = end - start;
    if (len > 0) {
    System.arraycopy(value, start+len, value, start, count-end);
         count -= len;
    }
    return this;
    }

Es wird also (fast) das ganze interne Array kopiert. Mit der neuen Implementierung sollte das eigentlich nicht geschehen. Wo war also das Problem? Der einzige Punkt, an dem etwas schiefgehen könnte, wäre die substring()-Methode. Also auch hier in den Sourcecode geschaut und in den Zeilen 1913 und 1914 wird nach ein paar checks ein String-Konstruktor aufgerufen:

return ((beginIndex == 0) && (endIndex == value.length)) ? this
                : new String(value, beginIndex, subLen);

Der Konstruktor tut nun folgendes:

public String(char value[], int offset, int count) {
    //checks...
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

Es wird also wieder das ganze Array kopiert! Als ich schon fast anfangen wollte, das ganze noch einmal in C oder C++ zu implementieren, fiel mir auf, dass die Methdoen in den Recognizern eigentlich keine Strings, sondern nur eine CharSequence als Eingabe benötigen. Lange Rede, kurzer Sinn: Hier ist meine eigene Implementierung von CharSequence ohne Array-Kopiererei.

public class MyCharSequence implements CharSequence {

    private String str;
    private int offset;
    private int end;

    public MyCharSequence(String str, int offset, int end) {
        super();
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (end - offset < 0) {
            throw new StringIndexOutOfBoundsException(end - offset);
        }
        if(str.length() - end < 0) {
            throw new StringIndexOutOfBoundsException(end);
        }
        this.str = str;
        this.offset = offset;
        this.end = end;
    }

    @Override
    public char charAt(int index) {
        if(index < 0) {
            throw new StringIndexOutOfBoundsException(index);
        }
        index += offset;
        if (index >= end) {
            throw new StringIndexOutOfBoundsException(index - offset);
        }
        return str.charAt(index);
    }

    @Override
    public int length() {
        return end - offset;
    }

    @Override
    public CharSequence subSequence(int start, int end) {
        if (start < 0) {
            throw new StringIndexOutOfBoundsException(start);
        }
        if (end > length()) {
            throw new StringIndexOutOfBoundsException(end);
        }
        return new MyCharSequence(str, start + offset, end + offset);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        for(int i = offset; i<end; i++) {
            sb.append(str.charAt(i));
        }
        return sb.toString();
    }

    public void setEnd(int end) {
        if (end - offset < 0) {
            throw new StringIndexOutOfBoundsException(end - offset);
        }
        if(str.length() - end < 0) {
            throw new StringIndexOutOfBoundsException(end);
        }
        this.end = end;
    }
}

Dann noch folgendes ändern:

public int recognize(int retVal, String longString, int start, int end) {
    String recognizeString = new MyCharSequence(longString, start, end);
    //other code...
    return retVal;
}

Und schon war die Laufzeit bei 5 Minuten!