Laboratorium 9 - klasyfikacja dokumentów tekstowych

Celem zadania jest predykcja autora tekstu na podstawie jego zawartości. Przetestujemy dwa klasyfikatory:

  • Drzewo decyzyjne
  • Naiwny klasyfikator Bayesa

1. Zbiory danych

Podczas ćwiczeń będziemy przetwarzali dane tekstowe pochodzące z 5 książek z przełomu XIX i XX wieku

  1. Reymont: Ziemia Obiecana
  2. Żuławski: Na srebrnym globie
  3. Sienkiewicz: W pustyni i w puszczy
  4. Sienkiewicz: Rodzina Połanieckich
  5. Żeromski: Syzyfowe prace

Zawartość książek została podzielona na zdania i utworzono 8 zbiorów dokumentów:

  • złożonych z 10, 5, 3 i 1 zdań
  • obejmujących treść wszystkich książek (five-books*.csv)
  • obejmujących treść pierwszych dwóch książek (two-books*.csv)

Każdy element zbioru danych zawiera informacje o autorze, książce (work), treść (content) oraz formy podstawowe wyrazów, tzw. lematy: content_stemmed. Zbiory są zapisane w formacie UTF-8

Archiwum ZIP zawierające zbiory danych

:!: Uwaga. W przypadku pojawienia się wyjątków ( serializer kryo …) należy ustawić dodatkowe opcje VM (jak na pierwszym laboratorium). Wybieramy Edit configuration (przy przycisku Run) i dodajemy poniższe opcje w polu VM options.

--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.lang.invoke=ALL-UNNAMED
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=java.base/java.net=ALL-UNNAMED
--add-opens=java.base/java.nio=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED
--add-opens=java.base/sun.nio.cs=ALL-UNNAMED
--add-opens=java.base/sun.security.action=ALL-UNNAMED
--add-opens=java.base/sun.util.calendar=ALL-UNNAMED

na podstawie tej dyskusji

2. AuthorRecognitionDecisionTree

Przeanalizujemy zawartośc zbioru danych two-books-all-1000-10-stem.csv i zbudujemy klasyfikator oparty na algorytmie DecisionTreeClassifier.

2.1 Analiza zbioru danych

1. Załaduj zbiór danych

Dataset<Row> df = spark.read().format("csv")
        .option("header", "true")
        .option("delimiter",",")
        .option("quote","\'")
        .option("inferschema","true")
        .load("data/books/two-books-all-1000-10-stem.csv");

2. Wyświetl autorów i utwory (użyj funkcji select…distinct

3. Policz liczbę dokumentów poszczególnych autorów. Użyj funkcji groupBy … count. Czy zbiór jest zrównoważony?

4 Policz średnie długości tekstów w kolumnie content dla poszczególnych autorów. Dodaj wpierw kolumnę z długością, a następnie zgrupuj i zastosuj avg jako funkcję agregująca.

2.2 Tokenizacja (wydzielanie symboli)

Użyj klasy RegexTokenizer. Zwróć uwagę na separatory. W oryginalnych plikach tekstowych pojawiały się specyficzne znaki. Jeśli nie zostaną zdefiniowane jako separatory, zostaną zidentyfikowane jako słowa (lub ich części). Na ich podstawie łatwo można zidentyfikować źródło, a więc i autora.

        String sep = "[\\s\\p{Punct}—…”„]+";
        RegexTokenizer tokenizer = new RegexTokenizer()
                .setInputCol("content")
                .setOutputCol("words")
                .setPattern(sep);
        var df_tokenized = tokenizer.transform(df);
        df_tokenized.show();

2.3 Konwersja do postaci Bag of Words

Reprezentacja BoW to odwzorowanie (słowo→liczba wystąpień).

  • Budowana jest za pomocą transformacji CountVectorizer, która:
    • Tworzy słownik słów (zazwyczaj uporządkowany według liczby wystąpień)
    • Zlicza wystąpienia danego słowa w dokumencie
    • Zamienia tekst na rzadki wektor, którego i-ty element to liczba wystąpień i-tego słowa w słowniku

1. Zdefiniuj parametry i przekonwertuj zbiór danych

CountVectorizer countVectorizer = new CountVectorizer()
        .setInputCol("words")
        .setOutputCol("features")
        .setVocabSize(10_000)  // Set the maximum size of the vocabulary
        .setMinDF(2);           // Set the minimum number of documents in which a term must appear
 
CountVectorizerModel countVectorizerModel = countVectorizer.fit(df_tokenized);
 
Dataset<Row> df_bow = countVectorizerModel.transform(df_tokenized);
df_bow.select("words","features").show(5);

2. Wyświetl słowa i wektor features dla pierwszego wiersza (dokumentu). Wywołanie row.get(index) zwraca odpowiedni element wiersza

3. SparseVector ma metodę indices(), która zwraca indeksy niezerowych elementów. To równocześnie indeksy słów w słowniku, który możesz pobrać za pomocą countVectorizerModel.vocabulary(). Dla pierwszego wiersza wyświetl odwzorowanie (słowo→liczba wystąpień).

i -> 13.000000
się -> 8.000000
w -> 8.000000
na -> 3.000000
z -> 4.000000
do -> 3.000000
a -> 1.000000
co -> 1.000000
za -> 1.000000
jeszcze -> 2.000000
ze -> 1.000000
...

Tego typu słowa to tzw. stopwords. Najczęściej przy kategoryzacji tekstów są one usuwane, ale niekoniecznie w przypadku identyfikajci autorów.

2.4 Budowa klasyfikatora

1. Przed przystąpieniem do klasyfikacji należy przekonwertować nazwiska autorów do postaci etykiet numerycznych

StringIndexerModel labelModel = labelIndexer.fit(df_bow);
df_bow = labelModel.transform(df_bow);
df_bow.show();

2. Konfiguracja algorytmu, uczenie i predykcja

DecisionTreeClassifier dt = new DecisionTreeClassifier()
        .setLabelCol("label")
        .setFeaturesCol("features")
        .setImpurity("gini")  // lub entropy
        .setMaxDepth(30);
 
DecisionTreeClassificationModel model = dt.fit(df_bow);
 
Dataset<Row> df_predictions = model.transform(df_bow);
df_predictions.show();

2.5 Ocena klasyfikatora

Użyj odpowiednio skonfigurowanej klasy MulticlassClassificationEvaluator (porównaj kolumny label i prediction). Wyznacz metryki f1 i accuracy. Spodziewane są wartości rzędu 0.99.

2.6 Istotność cech

Istotność cech (ang. feature importance) to ocena wkład danej cechy (tu słowa) na wynik klasyfikacji. W przypadku drzew decyzyjnych i lasów random forest używane są takie miary nieczystości, jak nieczystość Gini lub entropia. Istotność cechy j jest obliczana poprzez zsumowanie poprawy nieczystości we wszystkich węzłach, w których cecha j jest używana do podziału danych, ważone liczbą próbek docierających do tego węzła.

1. Odczytaj wektor istotności cech

SparseVector fi = (SparseVector) model.featureImportances();
System.out.println(fi);

2. Dla indeksów niezrowych wartości wektora fi.indices() wypisz odpowiedni element ze słownika oraz wartośc istotności. Jakie słowa przesądzają o wyniku klasyfikacji?

ziemi -> 0,220425
marta -> 0,173971
tom -> 0,069526
piotr -> 0,054149
ada -> 0,047203
wóz -> 0,034318
borowiecki -> 0,029524
morze -> 0,027261
i -> 0,023887
zgoła -> 0,020877
człowieku -> 0,020661
pan -> 0,020352
niebie -> 0,015931
zapewne -> 0,015552
snadź -> 0,015438
gór -> 0,015401
marty -> 0,015326
wybrzeżu -> 0,012599
tomasz -> 0,012525
ziemio -> 0,009627
patrzyłem -> 0,009584
...

Przy użyciu Weka możliwe było wyświetlenie drzewa. Poniżej przykład dla zbioru danych five-books*.csv. Przetwarzamy two-books*.csv więc cechy są trochę inne…

3. AuthorRecognitionGridSearchCVDecisionTree

Napisz funkcję, która przeprowadzi 3-krotną walidację krzyżową połaczoną z przeszukiwaniem siatki parametrów

private static void performGridSearchCV(SparkSession spark, String filename)

1. Podziel zbiór danych w proporci 0.8 i 0.2. Na zbiorze df_train będzie przeprowadzana walidacja krzyżowa

var splits = df.randomSplit(new double[]{0.8,0.2});
var df_train=splits[0];
var df_test=splits[1];

2. Powtórz poprzednie kroki definiując odpowiednie obiekty

3. Zdefiniuj ciąg przetwarzania

Pipeline pipeline = new Pipeline()
                .setStages(new PipelineStage[] {tokenizer, countVectorizer,labelIndexer, decisionTreeClassifier});

4. Zdefiniuj siatkę parametrów

ParamMap[] paramGrid = new ParamGridBuilder()
                .addGrid(countVectorizer.vocabSize(), new int[] {100, 1000,10_000})
                .addGrid(decisionTreeClassifier.maxDepth(), new int[] {10, 20,30})
                .build();

5. Skonfiguruje ewaluator. Zależy nam na wysokich wartościach metryki F1

MulticlassClassificationEvaluator evaluator = new MulticlassClassificationEvaluator()
        .setLabelCol("label")
        .setPredictionCol("prediction")
        .setMetricName("f1");

6. Zdefiniuj parametry walidacji krzyżowej. Jej wykonanie potrwa kilka minut.

CrossValidator cv = new CrossValidator()
        .setEstimator(pipeline)
        .setEvaluator(evaluator)
        .setEstimatorParamMaps(paramGrid)
        .setNumFolds(3)  // Use 3+ in practice
        .setParallelism(8);
 
CrossValidatorModel cvModel = cv.fit(df);

7. Pobierz najlepszy znaleziony model i wypisz parametry jego etapów

PipelineModel bestModel = (PipelineModel) cvModel.bestModel();
for(var s:bestModel.stages()){
    System.out.println(s);
}

Jakie były te parametry?

  • rozmiar słownika
  • głębokość drzewa

Wykorzystamy je w następnym etapie.

8. Wypisz średnie wartości metryki f1 dla badanych modeli – cvModel.avgMetrics()

9. Przetestuj efektywność najlepszego model na zbiorze testowym.

Wypisz wartości:

  • accuracy
  • weightedPrecision
  • weightedRecall
  • f1

4. AuthorRecognitionCVDecisionTree

1. Zmodyfikuj kod klasy AuthorRecognitionCVGridSearch. Wpisz znalezione parametry (wielkość słownika i głębokość drzewa). Zmień nazwę funkcji performGridSearchCV na performCV

2. Siatka poszukiwań może być pusta:

CrossValidator cv = new CrossValidator()
        .setEstimator(pipeline)
        .setEvaluator(evaluator)
        .setEstimatorParamMaps(new ParamGridBuilder().build())
        .setNumFolds(3)  // Use 3+ in practice
        .setParallelism(8);

3. Przetestuj wydajność klasyfikatora dla wszystkich plików. Zbierz wyniki w tabelce.

String filenames[]={
        "data/books/two-books-all-1000-1-stem.csv",
        "data/books/two-books-all-1000-3-stem.csv",
        "data/books/two-books-all-1000-5-stem.csv",
        "data/books/two-books-all-1000-10-stem.csv",
        "data/books/five-books-all-1000-1-stem.csv",
        "data/books/five-books-all-1000-3-stem.csv",
        "data/books/five-books-all-1000-5-stem.csv",
        "data/books/five-books-all-1000-10-stem.csv",
};

5. NaiveBayesDemo

Przetestujemy wpierw zasadę działania klasyfikatora NB na niewielkim przykładzie

1. Utworzymy zbiór danych. Teksty aaa, bbb,… to słowa

StructType schema = new StructType()
        .add("author", DataTypes.StringType, false)
        .add("content", DataTypes.StringType, false);
List<Row> rows = Arrays.asList(
        RowFactory.create("Ala","aaa aaa bbb ccc"),
        RowFactory.create("Ala","aaa bbb ddd"),
        RowFactory.create("Ala","aaa bbb"),
        RowFactory.create("Ala","aaa bbb bbb"),
        RowFactory.create("Ola","aaa ccc ddd"),
        RowFactory.create("Ola","bbb ccc ddd"),
        RowFactory.create("Ola","ccc ddd eee")
);
 
var df = spark.createDataFrame(rows,schema);

2. Dalej następuje standardowy ciąg przetwarzania

String sep = "[\\s\\p{Punct}—…”„]+";
RegexTokenizer tokenizer = new RegexTokenizer()
        .setInputCol("content")
        .setOutputCol("words")
        .setPattern(sep);
df = tokenizer.transform(df);
df.show();
 
System.out.println("-----------");
// Convert to BoW with CountVectorizer
CountVectorizer countVectorizer = new CountVectorizer()
        .setInputCol("words")
        .setOutputCol("features")
        .setVocabSize(10_000)  // Set the maximum size of the vocabulary
        .setMinDF(1)     // Set the minimum number of documents in which a term must appear
        ;
 
// Fit the model and transform the DataFrame
CountVectorizerModel countVectorizerModel = countVectorizer.fit(df);
df = countVectorizerModel.transform(df);
 
 
// Prepare the data: index the label column
StringIndexer labelIndexer = new StringIndexer()
        .setInputCol("author")
        .setOutputCol("label");
 
StringIndexerModel labelModel = labelIndexer.fit(df);
df = labelModel.transform(df);
df.show();

3. Definicja parametrów algorytmu i uczenie

NaiveBayes nb = new NaiveBayes()
        .setLabelCol("label")
        .setFeaturesCol("features")
        .setModelType("multinomial")
        .setSmoothing(0.01)
        ;
System.out.println(nb.explainParams());
 
NaiveBayesModel model = nb.fit(df);

5.1 Parametry modelu

Parametrami modelu są wartości prawdopodobieństw (logarytmów prawdopodobieństw) zwracane poprzez odpowiednie funkcje:

  • theta() - prawdopodobieństwo warunkowe (likelihood): $p(x_i|y_j)$
  • pi() - prawdopodobieństwo a-priori klasy: $p(y_j)$
  • sigma() - wartości wariancji - tylko dla rozkładu Gaussa

W przypadku rozkładu multinomial używane są tylko dwa pierwsze.

1. Wypisz informacje o zawartości słownika oraz etykietach

String[] vocab = countVectorizerModel.vocabulary();
String[] labels= labelModel.labels();

2. Następnie wpisz prawdopodobieństwa warunkowe (likelihhod) na podstawie zawartości macierzy theta. Funkcja apply() zwraca element wektora lub macierzy, np.theta.apply(i,j).

Oczekiwany wynik:

P(bbb|Ala)=0.415768 (log=-0.877629)
P(aaa|Ala)=0.415768 (log=-0.877629)
P(ddd|Ala)=0.083817 (log=-2.479114)
P(ccc|Ala)=0.083817 (log=-2.479114)
P(eee|Ala)=0.000830 (log=-7.094235)
P(bbb|Ola)=0.111602 (log=-2.192814)
P(aaa|Ola)=0.111602 (log=-2.192814)
P(ddd|Ola)=0.332597 (log=-1.100825)
P(ccc|Ola)=0.332597 (log=-1.100825)
P(eee|Ola)=0.111602 (log=-2.192814)

2. Wypisz wartości prawdopodobieństw a-priori

P(Ala)=0,571225 (log=-0,559972)
P(Ola)=0,428775 (log=-0,846823)

5.2 Predykcja

Sprawdźmy, jakie wartości prawdopodobieństw i etykieta zostaną wyznaczone dla przykładowego wektora cech. (Zakodowane wyrazy to bbb ddd ddd ccc.)

// bbb ddd ccc ddd
var testData = new DenseVector(new double[]{1,0,2,1,0});

Dodaj instrukcje:

var proba = model.predictRaw(testData);
System.out.println("Pr:["+ Math.exp(proba.apply(0))+", "+Math.exp(proba.apply(1)));
var predLabel = model.predict(testData);
System.out.println(predLabel);

5.3 Jak są obliczane surowe prawdopodobieństwa

Dla Multininomial NB stosowanym rozkładem jest rozkład nazywany wielomanowym lub wielokrotnym (ang. multinomial)

Jeżeli mamy wektor prawdopodobieństw $[0.2, 0.3,0.1, 0.4]$ to prawdopodobieństwo wystąpienia danych $x=[1,0,2,1]$ wynosi $p(x)=\frac{7!}{1!\cdot 0!\cdot 2! \cdot 4!}\cdot 0.2^1 \cdot 0.3^0 \cdot 0.1^2\cdot 0.4^1$. Przenosimy to także na sytuację, kiedy dane $x$ zostały wyskalowane (nie są liczbami całkowitymi).

Czyli wynikowe prawdopodobieństwo wynosi:

$p(x)=C(x)\prod_{i=1,n}p_i^{x_i}$., gdzie $C(x)$ wyłącznie zależy od $x$ - jest odpowiednikiem $\frac{n!}{x_1!\dots x_n!}$

Logarytmując otrzymujemy:

$log(p(x))=\sum_{i=1,n}x_i\cdot log(p_i) + log(C(x))$

Aby obliczyć $log(p(x))$ wyznaczamy iloczyn skalarny wektora $x$ i wektora $log(p_i)$. Przy porównaniach prawdopodobieństw klas składnik $log(C(x))$ można pominąć, ponieważ, zależy wyłącznie od obserwacji i pojawi się bez zmian w równaniach dla każdej z klas.

1. Oblicz prawdopodobieństwa $p0$, $p1$ odpowiadajace etykietom 0 i 1 zgodnie z powyższym wzorem

System.out.printf(Locale.US,"log(p0)=%g p0=%g log(p1)=%g p1=%g\n",
                p0,Math.exp(p0),
                p1,Math.exp(p1));
        System.out.println("Wynik klasyfikacj:"+(p0>p1?0:1));

2. Ustaw parametr smoothing klasyfikatora na 0 i sprawdź wyniki

NaiveBayes nb = new NaiveBayes()
        .setLabelCol("label")
        .setFeaturesCol("features")
        .setModelType("multinomial")
        .setSmoothing(0.0)            // <<<<<<<<<<<<<
        ;

Niestety, obliczenia zaburza P(eee|Ala)=0.000000 (log=-Infinity). Ostatecznie prawdopodobieństwo etykiety 0 ma wartość NaN. W każdym przypadku, gdy jednym z argumentów porównania jest NaN, zwracana jest wartośc false, stąd rozbieżność wybranych etykiet.

Z tego powodu należy zawsze stosować nawet niewielki parametr wygładzania. W wielu bibliotekach przyjmuje się standardową wartość parametru $alpha=1$. Dla mniejszych wartości są one automatycznie ograniczane od dołu do 1e-10 https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.MultinomialNB.html.

6. AuthorRecognitionGridSearchCVNaiveBayes

Wykorzystaj kod klasy dla klasyfikatora DecisionTree. Tworząc siatkę parametrów wybierzemy typ modelu oraz wielkość słownika.

1. Użyj klasyfikatora NaiveBayes

NaiveBayes nb = new NaiveBayes()
        .setLabelCol("label")
        .setFeaturesCol("features")
        .setSmoothing(0.2);

2. Skonfiguruj następująco siatkę parametrów

var scalaIterable = scala.jdk.CollectionConverters.
        IterableHasAsScala(Arrays.asList("multinomial", "gaussian")).asScala();
 
ParamMap[] paramGrid = new ParamGridBuilder()
        .addGrid(countVectorizer.vocabSize(), new int[] {100, 1000,5_000,10_000})
        .addGrid(nb.modelType(),scalaIterable )
        .build();

3. Uruchom funkcję performGridSearchCV() dla zbioru danych two-books-all-1000-1-stem.csv

4. Odczytaj wartości parametrów

PipelineModel bestModel = (PipelineModel) cvModel.bestModel();
for(var s:bestModel.stages()){
     System.out.println(s);
}

7. AuthorRecognitionCVNaiveBayes

1. Wykorzystaj wcześniejszy kod dla przeszukiwania siatki parametrów (ale użyj pustej siatki)

2. W funkcji performCV() ustaw wielkość słownika oraz typ klasyfikatora

3. Wykonaj walidację krzyżową i testy dla następujących plików

String filenames[]={
        "data/books/two-books-all-1000-1-stem.csv",
        "data/books/two-books-all-1000-3-stem.csv",
        "data/books/two-books-all-1000-5-stem.csv",
        "data/books/two-books-all-1000-10-stem.csv",
 
        "data/books/five-books-all-1000-1-stem.csv",
        "data/books/five-books-all-1000-3-stem.csv",
        "data/books/five-books-all-1000-5-stem.csv",
        "data/books/five-books-all-1000-10-stem.csv",
};

Podobnie, jak poprzednio podziel plik w proporcji 0.8/0.2 i większą część wykorzystaj w walidacji krzyżowej, a mniejszą do finalnych testów.

4. Zbierz wyniki w postaci tabelki:

  • miara F1 dla walidacji krzyżowej na zbiorze treningowym
  • accuracy
  • precision
  • recall
  • F1 (dla zbioru testowego)

7. Wnioski

  • Który klasyfikator osiąga lepsze wyniki?
  • Porównaj czasy uczenia
  • Jak efektywność zależy od liczby klas i liczby zdań w dokumentach.
ed/lab_09.txt · Last modified: 2024/05/09 12:43 by pszwed
CC Attribution-Share Alike 4.0 International
Driven by DokuWiki Recent changes RSS feed Valid CSS Valid XHTML 1.0