Fork me on GitHub

Анализ тональности текста с помощью Weka

Имеется заданный набор документов для которых уже заранее известна тональность: good, neutral, bad. Для нового документа необходимо определить какая у него тональность из трёх указанных. Идеального решения тут не существует ну хотябы потому, что понятие хороший/плохой/злой часто субъективно :) Но всё-же можно попробовать взять среднюю температуру по больнице задействовав т.н. Машинное Обучение (ML). Причём в нашем случае это будет Машинное Обучение с учителем т.к. набор обучающих данных имеется изначально.

Очевидно главный показатель работы классификатора - точность. Поскольку в ML обычно требуется много экспериментировать с различными параметрами, то наряду с множеством open-source проектов наличие какого-никакого UI не может не радовать и у Weka UI есть.

screenshot

Подробней процесс классификации в Weka расписан тут. Теперь то же самое, что на картинке, но уже в терминале и без UI (фильтры Weka выглядят жутковато, но их необязательно набивать ручками - можно скопировать из UI что и сделано в конце анимации):

~$ P1=http://mazko.github.io/blog/posts/2016/04/26/ \
   P2=analiz-tonalnosti-teksta-s-pomoshchiu-weka/kino1k.tar.gz \
   sh -c 'curl -sL "${P1}${P2}" | tar xz'

~$ alias javam='java -Xmx2g -cp ./weka-3-8-0/*:.'

~$ javam weka.core.converters.TextDirectoryLoader \
   -dir kino1k > /tmp/weka-output$$.arff

# note loaded text attributes attributes and their order
# we shall use this order later in Java application
~$ head -n4 /tmp/weka-output$$.arff

# optionally switch to sh because bash requires additional escape
# of ! symbol which is used in NGramTokenizer delimiters
~$ javam weka.classifiers.meta.FilteredClassifier \
    -F "weka.filters.MultiFilter \
        -F \"weka.filters.unsupervised.attribute.StringToWordVector \
            -R first-last -W 4444 -prune-rate -1.0 -N 0 \
            -stemmer weka.core.stemmers.NullStemmer \
            -stopwords-handler weka.core.stopwords.Null \
            -M 1 -tokenizer \\\"weka.core.tokenizers.NGramTokenizer -max 3 -min 1 \\\"\" \
        -F \"weka.filters.supervised.attribute.AttributeSelection \
            -E \\\"weka.attributeSelection.InfoGainAttributeEval \\\" \
            -S \\\"weka.attributeSelection.Ranker -T 0.0 -N -1\\\"\"" \
    -W weka.classifiers.bayes.NaiveBayesMultinomial \
    -t /tmp/weka-output$$.arff \
    -d ./sentiment-naive-bayes-multinomial.1.3.ig.model -x 3

Дополнительно классификатор сериализуется в файл sentiment-naive-bayes-multinomial.1.3.ig.model что позволит его использовать с пользой для дела:

Classy.java

import java.util.ArrayList;
import java.util.stream.IntStream;

import weka.classifiers.meta.FilteredClassifier;
import weka.core.Attribute;
import weka.core.DenseInstance;
import weka.core.Instance;
import weka.core.Instances;
import weka.core.SerializationHelper;

public class Classy {
    public class ClassyResult {
        public final double score;
        public final String clazz;

        public ClassyResult(final String clazz, final double score) {
            this.score = score;
            this.clazz = clazz;
        }

        public String toString() {
            return this.clazz + " -> " + this.score;
        }
    }

    /** The actual classifier. */
    private final FilteredClassifier m_classifier;

    /** The actual classifier algorithm */
    private final String m_algorithm;

    public Classy(final String model) throws Exception {
        m_classifier = (FilteredClassifier) SerializationHelper.read(model);
        m_algorithm = m_classifier.getClassifier().getClass().getSimpleName();
    }

    /**
     * Method that converts a text message into an instance.
     * 
     * @param text
     *            the message content to convert
     * @param data
     *            the header information
     * @return the generated Instance
     */
    private Instances makeInstance(final String text) {

        // Create vector of attributes.
        final ArrayList<Attribute> fvWekaAttributes = new ArrayList<Attribute>(2);

        // Add attribute for holding messages.
        final Attribute attrText = new Attribute("text", (ArrayList<String>) null);
        fvWekaAttributes.add(attrText);

        // Add class attribute.
        // Order is important: head -n4 /tmp/weka-output$$.arff
        // ...
        // @attribute @@class@@ {neutral,bad,good}
        final ArrayList<String> fvClassVal = new ArrayList<String>(3);
        fvClassVal.add("neutral");
        fvClassVal.add("bad");
        fvClassVal.add("good");
        final Attribute attrClass = new Attribute("@@class@@", fvClassVal);
        fvWekaAttributes.add(attrClass);

        // Create dataset with initial capacity of 0, and set index of class.
        final Instances instances = new Instances("Rel", fvWekaAttributes, 0);
        instances.setClassIndex(instances.numAttributes() - 1);

        // Create and add the instance
        final Instance instance = new DenseInstance(2);
        instance.setValue(attrText, text);
        instances.add(instance);

        // DEBUG: if something goes wrong 
        // or to understand better what we've just
        // done you can always compare output:
        // head -n4 /tmp/weka-output$$.arff
        // @relation _any_name_is_valid
        // @attribute text string
        // @attribute @@class@@ {neutral,bad,good}
        // with generated instance:
        // System.out.println(instances);
        // @relation Rel
        // @attribute text string
        // @attribute @@class@@ {neutral,bad,good}
        // @data
        // ...

        return instances;
    }

    public ClassyResult[] assignClass(final String message) throws Exception {

        // Make message into instance.
        final Instances instances = makeInstance(message);

        // Get index of predicted class value.
        final Instance in = instances.instance(0);

        // Get the predicted probabilities
        final double[] ps = m_classifier.distributionForInstance(in);

        return IntStream.range(0, ps.length)
            .mapToObj(i -> new ClassyResult(in.classAttribute().value(i), ps[i]))
            .toArray(ClassyResult[]::new);
    }

    public String getAlgorithm() {
        return m_algorithm;
    }

}

App.java

import java.util.Arrays;
import java.util.Scanner;

public class App { 
    public static void main(final String[] args) throws Exception {
        System.out.println("Loading model...");
        final Classy instance = new Classy("./sentiment-naive-bayes-multinomial.1.3.ig.model");
        System.out.println("Model loaded");
        System.out.println("Model trained algorithm: " + instance.getAlgorithm());
        System.out.println("Now print text to analyze!");
        try(final Scanner scan = new Scanner(System.in)) {
            while (true) {
                System.out.print("> ");
                if (!scan.hasNextLine()) break;
                final String line = scan.nextLine();
                final Classy.ClassyResult[] result = instance.assignClass(line);
                System.out.println(Arrays.toString(result));
            }
        }

    } 
}

REPL:

~$ javac App.java Classy.java -cp ./weka-3-8-0/*:.
~$ java -cp ./weka-3-8-0/*:. App
Loading model...
Model loaded
Model trained algorithm: NaiveBayesMultinomial
Now print text to analyze!
> жизнь прекрасна
[neutral -> 0.32679098745501506, bad -> 0.23459195142944572, good -> 0.4386170611155391]
> всё плохо
[neutral -> 0.31243645980279694, bad -> 0.4502602266214787, good -> 0.2373033135757244]

В случае классификации текстовой информации качество может зависеть не только от выбора алгоритма но и от способа преобразования текстовых данных к математический виду - в случае Weka этим занимается StringToWordVector и одним из параметров у него задаётся тип токенайзера. Задача токенайзера вроде как несложная - преобразовать строку текста в массив слов, но как нетрудно убедиться на картинке ниже если не полениться и задействовать более качественный токенизатор, взятый например из Lucene, итоговая точность определения категорий в Weka-классификаторе повышается:

screenshot

Проверка, исходники:

~$ mvn clean test
   Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
~$ mvn clean install exec:java -Dexec.mainClass="weka.gui.GUIChooser"

Comments !

links

social