====== 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
- Reymont: Ziemia Obiecana
- Żuławski: Na srebrnym globie
- Sienkiewicz: W pustyni i w puszczy
- Sienkiewicz: Rodzina Połanieckich
- Ż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
{{ :ed:books.zip | 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 [[https://stackoverflow.com/questions/72724816/running-unit-tests-with-spark-3-3-0-on-java-17-fails-with-illegalaccesserror-cl|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 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 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 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...
{{ :ed:authors-dt.png?direct&400 |}}
===== 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 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 [[https://en.wikipedia.org/wiki/Multinomial_distribution|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.