Machine Learning pl

Jak wytrenować model językowy

W artykule tym dowiesz się, jak wytrenować model bazujący na danych w języku polskim.

Skorzystajmy w tym celu z analizy sentymentu. Jest to algorytm, dzięki któremu program potrafi zrozumieć sens tekstu i jest w stanie automatycznie określić jakie było nastawienie emocjonalne autora. Przykładem zastosowania tego typu aplikacji jest np. analiza ludzkich opinii, odczuć czy postaw na przykład wobec produktów, usług czy marek, co daje nam możliwość wykorzystywania jej w obszarach takich jak nauki społeczne, ekonomiczne a także biznes.

Model

Poniżej omówiona została budowa modelu jaki wytrenowałem. Jest on w stanie automatycznie zrozumieć sens wypowiedzi i na tej podstawie podjąć decyzję czy była ona pozytywna, czy nie.

Automat dobrze klasyfikuje recenzje typu:

“Paczka przyszła bardzo szybko. Wózek wygląda dobrze”.

lub

“Kupiłem telefon, niestety paczka była uszkodzona. Telefon nie działa jak należy, w związku z czym nie polecam tej firmy”.

Niestety, niekiedy widzę, że model podejmuje błędne decyzje, zwłaszcza kiedy wypowiedź jest niejednoznaczna np:

“Szybka przesyłka, niestety obiektyw przyszedł uszkodzony.”

Tu algorytm klasyfikuje ją jako pozytywną z 53% prawdopodobieństwem. Czyli tak naprawdę pół na pół.

Model wytrenowałem wykorzystując 300 000 wpisów internautów pochodzących z popularnego portalu. Automat działający na danych o takim rozmiarze ma prawo niekiedy podjąć błędną decyzję. W środowisku produkcyjnym wolumen ten powinien być znacznie większy i to o kilka rzędów wielkości.

W swoich pracach bazowałem na algorytmie Sebastiana Raschka [1], który udostępnił go publicznie na zasadach open source. Dodatkowo użyłem funkcjonalności dostępnych w bibliotece Scikit-learn. Kod aplikacji również udostępniam publicznie na tych samych zasadach pod tym adresem.

Jak to działa

Zaczynając pracę z algorytmami machine learning, potrzebujemy narzędzi takich jak środowisko języka Python, oraz zainstalowane narzędzie Jupyter notebook.

W naszych rozważaniach postaramy się skorelować dwie wielkości:

  • Treść napisaną językiem potocznym,

oraz

  • Ocenę numeryczną, czyli liczbę gwiazdek, jaką zdecydował się wystawić firmie lub produktowi internauta.

Dla nas, osób czytających opinie na portalach, skojarzenie relacji pomiędzy tymi wielkościami to sprawa oczywista. Postarajmy się, aby maszyna również zauważyła tę zależność.

Program składać się będzie z trzech modułów głównych:

  • Scrapera — programu hurtowo pobierającego wpisy z portalu internetowego służącego do wystawiania opinii na temat firm
    i produktów,
  • Modułu czyszczącego dane i klasyfikatora — czyli funkcji usuwających zbędne słowa, nieprawidłowe wpisy, oraz algorytmów trenujących model
  • Interfejsu www — czyli prostego formularza internetowego.

Scraper

Aby wytrenować model, potrzebujemy danych. W tym przypadku wpisów internautów oceniających produkty, sklepy bądź firmy, w jakich ostatnio wykupili dowolną usługę.

Są to krótkie teksty napisane językiem potocznym takie jak:

  • “Przesyłka na czas. Produkt zgodny z opisem.”
  • “Bateria w telefonie trzyma pół dnia. Nie polecam!”
  • Miła i sprawna obsługa. Gorąco polecam ten sklep 😉

Aby pozyskać takie dane w sposób automatyczny, napisałem program, który pobrał je hurtowo z pewnego portalu służącego do oceny jakości produktów i usług. Wykorzystałem do tego celu środowisko języka Python z biblioteką BeautifulSoup. Całość opisana została w oddzielnym artykule zatytułowanym Pozyskiwanie danych do analizy -scraper.

Klasyfikator

Dysponując danymi zapisanymi w bazie, przejdźmy do ich obróbki. Całość prac nad kolejnym modułem prowadzić będziemy w narzędziu Jupyter notebook.

Jest to bardzo wygodne narzędzie IDE, w którym logiczne fragmenty kodu możemy przetwarzać w oddzielnych komórkach. To bardzo ważna funkcjonalność, ponieważ taka organizacja przestrzeni pozwala na wykonywanie długotrwałych operacji (np. trenowania modelu) tylko jeden raz. W kolejnych komórkach możemy prowadzać dalsze analizy wyników, bez konieczności ponawiania wcześniejszych operacji.

Połączmy się zatem z bazą danych a wynik zapytania przechowajmy w obiekcie DataFrame z biblioteki Pandas.

import sqlite3
import pandas as pd

cnx = sqlite3.connect('db.sqlite')
df = pd.read_sql_query("SELECT text_review, rating_star FROM reviews", cnx)

Podział na tokeny

Tokenizacja tekstu to proces podziału tekstu na słowa. Przy okazji tej operacji pozbądźmy się zbędnych słów (stopwords), które nic nie wnoszą do wypowiedzi.

stop = ['a','aby','ach','acz','aczkolwiek'   [...]   'że','żeby']

def tokenizer(text):    
    tokenized = [w for w in text.split() if w not in stop]
    return tokenized

Jak działa metoda tokenizer? Metoda przyjmuje jako parametr tekst np.:

tokenizer("Polecam tę firmę. 
           Przesyłka została zrealizowana 
           w ekspresowym czasie a towar spełnia 
           moje oczekiwania.")

natomiast zwraca listę składającą się z pojedynczych słów, bez słów nieistotnych takich jak: , w, a, moje.

Wyłuskujemy więc sedno wypowiedzi.

['polecam',
 'firmę',
 'przesyłka',
 'została',
 'zrealizowana',
 'ekspresowym',
 'czasie',
 'towar',
 'spełnia',
 'oczekiwania']

Przyjrzyjmy się teraz poniższemu kodowi. Funkcja stream_docs rozdziela każdy rekord na część tekstową i etykietę liczbową.

Podczas wstępnej obróbki danych w DataFrame, przyjąłem następującą zależność:

  • recenzja oceniona na 4 lub 5 gwiazdek jest pozytywna, czyli nadaję jej etykietę= 1
  • recenzja oceniona na 1, 2 lub 3 jest negatywna, czyli w tym wypadku nadaję jej etykietę= 0
Przyjemny w obsłudze ,1
Nie polecam !,0
Dobry produkt super marki,1
Bardzo fajny dużo funkcji i opcji.,1
Dobra jakość.Produkt polski.,1
warta polecenia,1
Zdecydowanie nie Ok,0
Funkcjonalny sprawdzający się w praktyce,1

Newralgiczną operację rozdzielenia wypowiedzi od wartości liczbowej owińmy blokiem try, except, ponieważ pojedyncze rekordy mogą być potencjalnie problematyczne i w takim wypadku po prostu pomińmy je.

def stream_docs(path):
    with open(path, 'r', encoding='utf-8') as csv:for line in csv:
            try:
                text, label = line[:-3], int(line[-2])   
            except Exception as e:
                print("Wystąpił błąd:",e)
             pass
       
            yield text, label

W tym miejscu podkreślmy pewną zasadę w obróbce danych przeznaczonych do trenowania modelu jaką znalazłem w literaturze, a dokładnie w książce “Big data — efektywna analiza danych” Viktora Mayer-Schonbergera.

Otóż:

“Pojedyncze rekordy danych nie stanowią dużej wartości samej w sobie.
W analizie najważniejszy jest trend.”

czy jakoś tak 🙂

Metoda get_minibach “porcjuje dane”. Pobiera je z metody stream_docs (czyli ma już rozdzielny tekst od etykiety) i zwraca je w porcjach określonych parametrem size.

Dosłownie za chwilę użyjemy jej aby wydzieliła nam porcję po 1000 rekordów które użyjemy w pętli.

def get_minibatch(doc_stream, size):
    docs, y = [], []
    try:
        for _ in range(size):
            text, label = next(doc_stream)
            docs.append(text)
            y.append(label)
    except StopIteration:
        print("Błąd: ", text[:60], "label", label)
        return None, None
    return docs, y

Przystąpmy do klasyfikacji

Na początek musimy zaznajomić się z działaniem modelu worka słów na przykładzie prostej metody CountVectorizer z pakietu scikit-learn.

Przyjrzyjmy się dwóm, przykładowym zdaniom:

  1. Programowanie to dziedzina informatyki”,
  2. Geometria to dziedzina matematyki”,

Zbudujmy listę docs z podanych wyżej zdań i użyjmy jej jako parametr metody fit_transform klasy CountVectorizer.

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer

count = CountVectorizer()
docs = np.array(['Programowanie to dziedzina informatyki',
                 'Geometria to dziedzina matematyki'])

bag = count.fit_transform(docs)

Wyświetlmy zawartość słownika:

print(count.vocabulary_)

{'programowanie': 3, 'to': 5, 'dziedzina': 1, 'informatyki': 2, 'geometria': 0, 'matematyki': 4}

Powyższe liczby to numery pozycji w tablicy (indeksy).

Wyświetlmy teraz wektor cech:

print(bag.toarray())

[[0 1 1 1 0 1]
 [1 1 0 0 1 1]]

Przystąpmy teraz do procesu ważenia częstości termów — odwrotną częstością w dokumentach.

Użyjmy klasy TfidfTransformer przyjmującej jako parametr częstość terminów przechowywanych w klasie CountVectorizer

from sklearn.feature_extraction.text import TfidfTransformer

tfidf = TfidfTransformer(use_idf=True, norm='l2', smooth_idf=True)

print(tfidf.fit_transform(count.fit_transform(docs)).toarray())

[[0.      0.41    0.58    0.58    0.      0.41]
 [0.58    0.41    0.      0.      0.58    0.41]]

indeks(numer kolumny):
  0       1       2       3       4       5

Mamy tu dwie linie a w każdej z nich zmiennoprzecinkowe wartości liczbowe. Pierwsza kolumna (indeks zerowy) odpowiada słowu ‘geometria’. Jak widać występuje tylko w zdaniu drugim i ma dość wysoką rangę 0,58. Kolumna druga (indeks = 1) to słowo ‘dziedzina’. Występuje w obu zdaniach i przez to jego ranga spada do 0.41. Nie jest to więc nośnik ważnych informacji, występuje często w różnych dokumentach.

Wynotujmy teraz liczby o największym znaczeniu:

  • w zdaniu pierwszym to słowa o indeksach 2 i 3 czyli ‘informatyki’ i ‘programowanie’.
  • w zdaniu drugim to słowa o indeksie zerowym i czwartym, czyli ‘geometria’ i ‘matematyka

Po zastosowaniu powyższych operacji, w stosunkowo prosty sposób wyłuskaliśmy sedno każdego ze zdań.

Algorytm oparty o funkcję CountVectorizer nie należy do zbyt wydajnych, aczkolwiek jakość wytrenowanego w ten sposób modelu pozostaje na wysokim poziomie.

Zapoznajmy się z działaniem bardziej wydajnej funkcji HashingVectorizer.

Cel działania obu funkcji HashingVectorizer i CountVectorizer jest identyczny. W obu przypadkach chodzi o przekonwertowanie zbioru dokumentów (wypowiedzi) tekstowych na macierz wystąpień tokenów. Różnica polega na tym, że HashingVectorizer nie przechowuje w pamięci wynikowego słownika wyrazów, zużywa dzięki temu bardzo małą ilość pamięci. Jest to więc dobrze skalujące się rozwiązanie, zwłaszcza przy dużych zbiorach danych.

Należy zdawać sobie sprawę z istnienia pewnej wady użycia funkcji HashingVectorizer. Jako, że do obliczeń użyta została funkcja skrótu, mogą wystąpić kolizje, czyli różne tokeny mogą posiadać ten sam skrót i przez to być mapowane na ten sam indeks cech. Łatwo jest jednak zminimalizować ten problem stosując duże wartości parametru n_features (w naszym przypadku zastosowaliśmy 2 ** 21 = 2 097 152).

from sklearn.feature_extraction.text import HashingVectorizer
from sklearn.linear_model import SGDClassifier

vect = HashingVectorizer(decode_error='ignore', 
                         n_features=2**21,
                         preprocessor=None, 
                         tokenizer=tokenizer)

Czy pamiętasz stopkę w naszym DataFrame?

Mówi ona tyle, że udało się pozyskać z portalu ponad 382 tyś. wpisów internautów. Nie używajmy od razu 100% wszystkich danych. Niewielka część niech posłuży nam do końcowej oceny skuteczności modelu.

Dla równego rachunku, wytrenujmy nasz model używając jedynie 300 000. rekordów. Czyli wykonajmy 300 iteracji po 1000 wpisów w każdym. Jak pamiętasz, porcje po 1000 zwróci nam metoda get_minibach.

clf = SGDClassifier(loss='log', random_state=1)
doc_stream = stream_docs(path='./input_data-pl.csv')
classes = np.array([0, 1])

for _ in range(300):
    try:
        X_train, y_train = get_minibatch(doc_stream, size=1000)
        X_train = vect.transform(X_train)          
        clf.partial_fit(X_train, y_train, classes=classes)
    except Exception as e:
        print("Błąd:", e)
        pass

A teraz w ten sam sposób przeprowadźmy ocenę skuteczności modelu. Tym razem bazując na tysiącu rekordów testowych, które pozostawiliśmy do testów.

Generalna zasada przy testowaniu modelu jest taka, że model powinniśmy testować wyłącznie danymi, których nie używaliśmy do trenowania. W przeciwnym wypadku otrzymamy model zbyt dobrze dopasowany do tego konkretnego przypadku, który może źle radzić sobie z prawdziwymi danymi, w środowisku produkcyjnym.

X_test, y_test = get_minibatch(doc_stream, size=1000)
X_test = vect.transform(X_test)
clf = clf.partial_fit(X_test, y_test)

print('Dokładność: %.3f' % clf.score(X_test, y_test))

Tu na marginesie jeszcze jedna uwaga praktyczna. Otóż trenowanie modelu jest procesem długotrwałym a wynik dostępny jest w pamięci do następnego restartu kernela. Jeśli nie chcemy wykonywać trenowania modelu za każdym razem, dokonajmy teraz serializacji obiektów.

Serializacja obiektu to proces, w którym hierarchia obiektów Pythona jest konwertowana na strumień bajtów, natomiast deserializacja to proces odwrotny, w której strumień bajtów, z pliku binarnego jest konwertowany na pierwotną hierarchię obiektów.

dest = os.path.join('pkl_objects')

pickle.dump(stop, open(os.path.join(dest, 'stopwords.pkl'), 'wb'), protocol=4)   
pickle.dump(clf, open(os.path.join(dest, 'classifier.pkl'), 'wb'), protocol=4)

W przypadku problemów z deserializacją w innym środowisku (napotkałem na taki problem instalując aplikację na serwerze PythonAnywhere), dobrze jest ujednolicić wersję biblioteki scikit-learn na dokładnie taką, jaką mamy zainstalowaną na komputerze na którym dokonywaliśmy serializacji.

pip3.8 install --user sklearn --upgrade scikit-learn==0.23.2

Po wykonaniu powyższych operacji mamy wytrenowany model a powstałe pliki dump, po deserializacji posłużą nam do przyszłej analizy tekstu. Ale tym zajmie się trzeci moduł aplikacji: aplikacja internetowa.

Aplikacja internetowa

Interfejs webowy napisany został w języku Python z wykorzystaniem frameworka Flask. W pliku główny, app.py, zaraz po zaimportowaniu niezbędnych bibliotek, program przystępuje do deserializacji modelu klasyfikującego.

cur_dir = os.path.dirname(__file__)

clf = pickle.load(open(os.path.join(cur_dir,
                 'pkl_objects',
                 'classifier.pkl'), 'rb'))

Jak wspomniałem wcześniej, algorytm aplikacji jest samouczący się, tak więc po odebraniu odpowiedzi zwrotnej od użytkownika, czy “diagnoza” algorytmu była trafna czy nie, klasyfikator automatycznie zostaje zaktualizowany, dzięki użyciu funkcji train.

def train(document, y):
    X = vect.transform([document])
    clf.partial_fit(X, [y])

Dodaliśmy również funkcję classify, jaka posłuży nam za chwilę do zwrócenia przewidywanej etykiety klasy dla danego tekstu.

def classify(document):
    label = {0: 'negatywna', 1: 'pozytywna'}
    X = vect.transform([document])
    y = clf.predict(X)[0]
    proba = np.max(clf.predict_proba(X))
    return label[y], proba

Poniżej zbudowaliśmy klasę ReviewForm tworzącą obiekt TextAreaField wyświetlany za pomocą szablonu reviewform.html

@app.route('/')

def index():
    form = ReviewForm(request.form)
    return render_template('reviewform.html', form=form)

Funkcja result,za pomocą metody POST pobiera dane z formularza i dokonuje klasyfikacji za pomocą wspomnianej wyżej funkcji classify.

Gotowe zmienne taki jak: content (tekst recenzji), prediction (prognoza) i probability (prawdopodobieństwo), przekazane zostają do szablonu.

def results():
    form = ReviewForm(request.form)

    if request.method == 'POST' and form.validate():
        review = request.form['moviereview']
        y, proba = classify(review)
        n = str(random.randint(1,6))

        return render_template('results.html',
                                content=review,
                                prediction=y,
                                probability=round(proba*100, 2))

    return render_template('reviewform.html', form=form)

Jeśli naciśniemy przycisk “Tak” lub “Nie”, wyświetlana jest strona z szablonem thanks.html który wyświetla podziękowanie za przyczynienie się udoskonalenia modelu klasyfikującego.

return render_template('thanks.html')

Na tym aplikacja kończy swoje działanie, nie wyczerpując oczywiście wszystkich możliwości trenowania i wykorzystywania wytrenowanego modelu predykcyjnego.

Tak jak pisałem wcześniej, model taki byłby potencjalnie przydatny w szeroko pojętym marketingu, w szczególności przy poszukiwaniu informacji zwrotnych o nastrojach i emocjach skoncentrowanych wokół marki czy produktu.


Źródła:

[1] Repozytorium GitHub Dr. Sebastiana Raschka https://github.com/rasbt