---
title: "Klasyfikacja recenzji filmów"
output: 
  html_notebook: 
    theme: cerulean
    highlight: textmate
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(warning = FALSE, message = FALSE)
```



Klasyfikacja dzieląca na dwie grupy (klasyfikacja binarna) jest najczęściej spotykanym problemem uczenia maszynowego. Analizując ten przykład, nauczysz się klasyfikować recenzje filmów — dzielić je na pozytywne i negatywne na podstawie ich treści.

## Zbiór danych IMDB


Będziemy pracować ze zbiorem IMDB: zbiorem 50 000 bardzo spolaryzowanych recenzji opublikowanych w serwisie Internet Movie Database. Recenzje zostały podzielone na zbiór treningowy (25 000 recenzji) i zbiór testowy (25 000 recenzji). Każdy z tych zbiorów składa się w połowie z recenzji pozytywnych i w połowie z recenzji negatywnych.

Dlaczego korzystamy z oddzielnych zbiorów? Wynika to z tego, że nigdy nie powinno się testować modelu uczenia maszynowego na tych samych danych, które były używane do jego trenowania! To, że model dobrze klasyfikuje dane treningowe, wcale nie oznacza tego, że będzie równie dobrze klasyfikował nowe dane, a tak naprawdę interesuje nas wydajność modelu podczas klasyfikacji nowych danych (znamy etykiety próbek treningowego zbioru danych, a więc to oczywiste, że nie musimy ich przewidywać za pomocą modelu). Model mógłby po prostu zapamiętać etykiety treningowego zbioru danych i być zupełnie nieprzydatny podczas przewidywania etykiet nowych recenzji. Zagadnienie to zostanie opisane w sposób bardziej szczegółowy w kolejnym rozdziale.

Zbiór IMDB, podobnie jak zbiór MNIST, jest dołączony do pakietu Keras. Zbiór ten został już przygotowany do analizy: recenzje (sekwencje słów) zostały zamienione na sekwencje wartości całkowitoliczbowych, w których każda wartość symbolizuje obecność w recenzji wybranego słowa ze słownika.

Poniższy kod załaduje zbiór danych (podczas uruchamiania go po raz pierwszy na dysk twardy Twojego komputera pobranych zostanie około 80 MB danych).

```{r, results='hide'}
library(keras)

imdb <- dataset_imdb(num_words = 10000)
c(c(train_data, train_labels), c(test_data, test_labels)) %<-% imdb
```

Argument num_words=10000 oznacza, że w treningowym zbiorze danych zostanie zachowanych tylko 10 000 słów, występujących najczęściej w tym zbiorze danych. Słowa występujące rzadziej zostaną pominięte. Rozwiązanie to umożliwia pracę z wektorem danych o rozmiarze umożliwiającym jego przetwarzanie.

Zmienne train_data i test_data są listami recenzji. Każda recenzja jest listą indeksów słów (zakodowaną sekwencją słów). Zmienne train_labels i test_labels zawierają etykiety w postaci zer i jedynek: 0 oznacza recenzję negatywną, a 1 oznacza recenzję pozytywną:

```{r}
str(train_data[[1]])
```

```{r}
train_labels[[1]]
```

Ograniczamy się do 10 000 najczęściej występujących słów, a więc będziemy mieli 10 000 wartości indeksów słów:

```{r}
max(sapply(train_data, max))
```

Dla ciekawskich — oto sposób na szybkie odkodowanie jednej z recenzji i odczytanie jej treści w języku angielskim:

```{r}
# Słownik word_index przypisuje słowom wartości indeksów.
word_index <- dataset_imdb_word_index()
# Odwracajac go, możemy przypisać indeksy do słów.
reverse_word_index <- names(word_index)
names(reverse_word_index) <- word_index
# Kod dekodujący recenzję. Zauważ, że indeksy są przesunięte o 3, ponieważ pod trzema pierwszymi indeksami  
# znajdują się indeksy symbolizujące „wypełnienie”, „początek sekwencji” i „nieznane słowo”.
decoded_review <- sapply(train_data[[1]], function(index) {
  word <- if (index >= 3) reverse_word_index[[as.character(index - 3)]]
  if (!is.null(word)) word else "?"
})
```

```{r}
cat(decoded_review)
```

## Przygotowywanie danych


List wartości całkowitoliczbowych nie można przekazać bezpośrednio do sieci neuronowej. Trzeba je zamienić na listę tensorów. Można to zrobić na dwa sposoby:

* Można dopełnić listy tak, aby miały takie same długości, i zamienić je na tensor wartości całkowitoliczbowych mający kształt (próbki, indeksy_słów), a następnie w roli pierwszej warstwy sieci neuronowej zastosować warstwę mogącą przetwarzać tensory wartości całkowitoliczbowych (warstwę Embedding — więcej informacji na jej temat znajdziesz w dalszej części tej książki).
* Można zakodować listy tak, aby zamienić je w wektory zer i jedynek. Oznacza to np. zamienienie sekwencji [3, 5] na wektor mający 10 000 wymiarów, który będzie wypełniony samymi zerami, a tylko pod indeksami o numerach 3 i 5 znajdą się jedynki. W takiej sytuacji pierwszą warstwą naszej sieci mogłaby być warstwa Dense, która potrafi obsłużyć wektory danych zmiennoprzecinkowych.

Skorzystajmy z drugiego rozwiązania i zamieńmy dane na wektory. W celu zachowania przejrzystości kodu zrobimy to ręcznie.

```{r}
vectorize_sequences <- function(sequences, dimension = 10000) {
  # Tworzy macierz wypełnioną zerami o kształcie (length(sequences), dimension).
  results <- matrix(0, nrow = length(sequences), ncol = dimension)
  for (i in 1:length(sequences))
    # Pod wybranymi indeksami umieszcza wartość 1.
    results[i, sequences[[i]]] <- 1
  results
}

# Zbiór treningowy w postaci wektora.
x_train <- vectorize_sequences(train_data)
# Zbiór testowy w postaci wektora.
x_test <- vectorize_sequences(test_data)
```

Teraz próbki wyglądają tak:

```{r}
str(x_train[1,])
```

Musimy jeszcze wykonać operację zamiany etykiet próbek na wektory:

```{r}
y_train <- as.numeric(train_labels)
y_test <- as.numeric(test_labels)
```

Teraz dane mogą zostać przetworzone przez sieć neuronową.

## Budowa sieci neuronowej

Dane wejściowe są wektorami, a etykiety mają formę wartości skalarnych (jedynek i zer): to najprostsza sytuacja, z jaką można mieć do czynienia. Tego typu problemy najlepiej jest rozwiązywać za pomocą sieci prostego stosu w pełni połączonych warstw (dense) z aktywacjami layer_dense
(units = 16, activation = "relu").

Argument przekazywany do każdej warstwy dense (16) jest liczbą ukrytych jednostek warstwy. Jednostka ukryta jest wymiarem przestrzeni reprezentacji warstwy. W rozdziale 2. pisałem o tym, że każda warstwa Dense z aktywacją relu implementuje następujący łańcuch operacji tensorowych:

`output = relu(dot(W, input) + b)`

Przy 16 ukrytych jednostkach macierz wag W będzie miała kształt (wymiar_wejściowy, 16): iloczyn skalarny macierzy W będzie rzutował dane wejściowe na 16-wymiarową przestrzeń reprezentacji (następnie dodawany jest wektor wartości progowych b i wykonywana jest operacja relu). Wymiary przestrzeni reprezentacji danych można rozumieć jako „stopień swobody, jaką dysponuje sieć podczas nauki wewnętrznych reprezentacji danych”. Zwiększenie liczby ukrytych jednostek (zwiększenie liczby wymiarów przestrzeni reprezentacji) pozwala sieci na uczenie się bardziej skomplikowanych reprezentacji, ale działanie takiej sieci będzie wymagało większej mocy obliczeniowej i może prowadzić do wytrenowania niechcianych parametrów (prawidłowości, które poprawią wydajność przetwarzania treningowego zbioru danych, ale będą bezużyteczne podczas przetwarzania danych testowych).

Pracując z warstwami dense, należy odpowiedzieć sobie na dwa pytania dotyczące architektury sieci:

* Ile warstw należy zastosować?
* Ile ukrytych jednostek należy wybrać w każdej z warstw?

Warstwy pośrednie będą korzystały z funkcji aktywacji relu, a ostatnia warstwa będzie korzystała z funkcji aktywacji sigmoid, co pozwoli na wygenerowanie wartości znajdującej się w zakresie od 0 do 1 określającej prawdopodobieństwo tego, że dana recenzja jest pozytywna. Funkcja relu (wyprostowana jednostka liniowa) jest funkcją, która ma wyzerowywać negatywne wartości (patrz rysunek 3.4), a funkcja sigmoid „upycha” wartości tak, aby znalazły się w zakresie od 0 do 1 (patrz rysunek 3.5), co pozwala sieci na generowanie wartości, które można interpretować jako prawdopodobieństwo.

Here's what our network looks like:

![3-layer network](img\3_4.png)

Oto kod implementacji sieci za pomocą pakietu Keras (przypomina on implementację sieci z zaprezentowanego wcześniej przykładu przetwarzania zbioru MNIST:

```{r}
library(keras)

model <- keras_model_sequential() %>% 
  layer_dense(units = 16, activation = "relu", input_shape = c(10000)) %>% 
  layer_dense(units = 16, activation = "relu") %>% 
  layer_dense(units = 1, activation = "sigmoid")
```

Na koniec musimy wybrać funkcję straty i optymalizator. Pracujemy nad problemem klasyfikacji binarnej, a sieć zwraca wartości prawdopodobieństwa (na końcu sieci znajduje się warstwa jednej jednostki z funkcją aktywacji sigmoid), a więc najlepiej jest skorzystać z funkcji straty binary_crossentropy (binarnej entropii krzyżowej). Nie jest to jedyna opcja, z której możemy skorzystać. Możemy również użyć np. funkcji średniego błędu kwadratowego mean_squared_error, ale entropia krzyżowa jest zwykle najlepszą opcją w przypadku modeli zwracających wartości prawdopodobieństwa. Termin entropia krzyżowa wywodzi się z teorii informacji. Jest to miara odległości między rozkładami prawdopodobieństwa a w tym przypadku rozkładem prawdziwych wartości i rozkładem przewidywanych wartości.

Oto kod konfigurujący model. Wybieramy w nim optymalizator rmsprop i funkcję straty binary_crossentropy. Zauważ, że podczas trenowania monitorować będziemy również dokładność (accuracy).

```{r}
model %>% compile(
  optimizer = "rmsprop",
  loss = "binary_crossentropy",
  metrics = c("accuracy")
)
```

Metryka, optymalizator i funkcja straty są definiowane za pomocą łańcuchów. Jest to możliwe, ponieważ rmsprop, binary_crossentropy i accuracy to pakiety wchodzące w skład biblioteki Keras. Czasami zachodzi konieczność skonfigurowania parametrów optymalizatora lub przekazania samodzielnie wykonanej funkcji straty lub funkcji metryki. Można to zrobić, przekazując instancję klasy optymalizatora jako argument optimizer  i przekazując funkcję obiektów jako argumenty loss i metrics.


```{r}
model %>% compile(
  optimizer = optimizer_rmsprop(lr=0.001),
  loss = "binary_crossentropy",
  metrics = c("accuracy")
) 
```


```{r}
model %>% compile(
  optimizer = optimizer_rmsprop(lr = 0.001),
  loss = loss_binary_crossentropy,
  metrics = metric_binary_accuracy
) 
```

## Walidacja modelu

W celu monitorowania dokładności modelu w czasie trenowania utworzymy zbiór danych, które nie były używane do trenowania modelu. Zrobimy to, odtłaczając 10 000 próbek od treningowego zbioru danych.

```{r}
val_indices <- 1:10000

x_val <- x_train[val_indices,]
partial_x_train <- x_train[-val_indices,]

y_val <- y_train[val_indices]
partial_y_train <- y_train[-val_indices]
```

Teraz będziemy trenować model przez 20 epok (wykonamy 20 iteracji wszystkich próbek znajdujących się w tensorach x_train i y_train) z podziałem na wsady po 512 próbek. Jednocześnie będziemy monitorować funkcje straty i dokładności modelu przy przetwarzaniu 10 000 próbek, które przed chwilą odłożyliśmy na bok. W tym celu musimy przekazać zbiór walidacyjny (kontrolny) jako argument validation_data:

```{r, echo=TRUE, results='hide'}
model %>% compile(
  optimizer = "rmsprop",
  loss = "binary_crossentropy",
  metrics = c("accuracy")
)

history <- model %>% fit(
  partial_x_train,
  partial_y_train,
  epochs = 20,
  batch_size = 512,
  validation_data = list(x_val, y_val)
)
```

W przypadku trenowania na procesorze CPU przetworzenie jednej epoki procesu zajmuje mniej niż 2 sekundy — cały proces trwa około 20 sekund. Pod koniec każdej epoki algorytm zatrzymuje się na chwilę, ponieważ model oblicza stratę i dokładność, korzystając z 10 000 próbek walidacyjnego zbioru danych.

Zwróć uwagę na to, że wywołanie metody fit() zwraca obiekt history (historia). Przyjrzyjmy się mu:

```{r}
str(history)
```

```{r}
plot(history)
```

W górnej części rysunku przedstawiono dokładność, a w dolnej stratę. Uzyskane przez Ciebie wartości mogą nieco odbiegać od tych widocznych na moim zrzucie z powodu losowego charakteru procesu inicjalizacji sieci.ork.

Jak widać, strata trenowania spada z każdą kolejną epoką, a dokładność trenowania wzrasta. Tego oczekujemy od optymalizacji algorytmem spadku gradientu — wartość, którą staramy się minimalizować, powinna maleć w każdej kolejnej iteracji, ale w czwartej epoce strata walidacji i dokładność walidacji rosną. To właśnie przykład sytuacji, przed którą ostrzegałem wcześniej — model sprawdzający się lepiej na treningowym zbiorze danych wcale nie musi sprawdzać się lepiej podczas przetwarzania nowych danych. W praktyce jest to przykład nadmiernego dopasowania — po drugiej epoce model jest zbytnio optymalizowany na treningowym zbiorze danych i uczy się konkretnej reprezentacji treningowego zbioru danych, a nie ogólnej wizji sprawdzającej się również poza treningowym zbiorem danych.

W tym przypadku nadmiernemu dopasowaniu możemy zapobiec, przerywając działanie algorytmu po 3 epokach, ale możemy skorzystać z wielu technik zapobiegających nadmiernemu dopasowaniu modelu, które opiszę w kolejnym rozdziale.

Przeprowadźmy trenowanie nowej sieci od podstaw (zróbmy to przez cztery epoki), a następnie dokonajmy ewaluacji na podstawie testowego zbioru danych.

```{r, echo=TRUE, results='hide'}
model <- keras_model_sequential() %>% 
  layer_dense(units = 16, activation = "relu", input_shape = c(10000)) %>% 
  layer_dense(units = 16, activation = "relu") %>% 
  layer_dense(units = 1, activation = "sigmoid")

model %>% compile(
  optimizer = "rmsprop",
  loss = "binary_crossentropy",
  metrics = c("accuracy")
)

model %>% fit(x_train, y_train, epochs = 4, batch_size = 512)
results <- model %>% evaluate(x_test, y_test)
```

```{r}
results
```

To dość naiwne rozwiązanie pozwoliło uzyskać dokładność na poziomie 88%. Dopracowane modele powinny zbliżyć się do 95%.

## Używanie wytrenowanej sieci do generowania przewidywań dotyczących nowych danych

Po wytrenowaniu sieci możemy jej użyć w celu zrobienia czegoś praktycznego. Aby wygenerować wartość określającą prawdopodobieństwo tego, że recenzja jest pozytywna, wystarczy skorzystać z metody predict:

```{r}
model %>% predict(x_test[1:10,])
```

Jak widać, w przypadku nowych próbek sieć jest bardzo pewna swojego werdyktu (generuje wartości zbliżone do 0,99 lub 0,01), ale w przypadku innych generuje o wiele mniej pewne wyniki, takie jak 0,6 lub 0,4.

## Dalsze eksperymenty

Oto eksperymenty, które pomogą Ci utwierdzić się w przekonaniu, że wybraliśmy całkiem sensowną architekturę, z tym że można ją jeszcze usprawnić:

* Korzystaliśmy z dwóch warstw ukrytych. Spróbuj dodać jedną lub trzy warstwy ukryte i sprawdź, jak wpłynie to na dokładność walidacji i testu.
* Spróbuj użyć warstw z większą lub mniejszą liczbą ukrytych jednostek: wypróbuj warstwy z np. 32 i 64 jednostkami.
* Zamiast funkcji straty binary_crossentropy skorzystaj z funkcji straty mse.
* Wypróbuj działanie funkcji aktywacji tanh (funkcja ta była popularna na początku rozwoju sieci neuronowych) — zastąp nią funkcję relu.


## Wnioski


Oto wnioski, które należy wynieść z tego przykładu:

* Zwykle dane wymagają przeprowadzenia wstępnej obróbki, po której można skierować je w formie tensorów do wejścia sieci neuronowej. Sekwencja słów może być przedstawiona w formie wektorów wartości binarnych, ale można to zrobić również na inne sposoby.
* Stosy warstw dense z aktywacją relu mogą służyć do rozwiązywania różnych problemów (między innymi klasyfikacji tonu wypowiedzi). W związku z tym najprawdopodobniej będziesz często korzystać z nich w przyszłości.
* W przypadku problemu klasyfikacji binarnej (dwie klasy wyjściowe) na końcu sieci powinna znajdować się warstwa dense z jedną jednostką i funkcją aktywacji sigmoid — wartości wyjściowe generowane przez sieć powinny być skalarami znajdującymi się w zakresie od 0 do 1 (powinny określać prawdopodobieństwo).
* W takiej konfiguracji warstwy wyjściowej sieci funkcją straty powinna być binarna entropia krzyżowa (binary_crossentropy).
* Optymalizator rmsprop jest — ogólnie rzecz biorąc — dobrym wyborem do każdego problemu. W związku z tym masz o jedną rzecz mniej do przeanalizowania.
* Sieci neuronowe wraz z coraz lepszym poznawaniem danych treningowych zaczynają się nadmiernie do nich dopasowywać, co prowadzi do pogorszenia rezultatów przetwarzania nowych danych. Musisz stale monitorować wydajność sieci podczas przetwarzania danych niewchodzących w skład zbioru treningowego.

