Przetwarzasz teksty, robisz NLP, TorchText Ci pomoże!

Biblioteka, która wybawiła mnie przy wielu żmudnych zadaniach związanych z przetwarzaniem tekstu w Pytorch. TorchText zdecydowanie upraszcza wczytywanie i przygotowanie danych tekstowych do podania do sieci neuronowej.

Wpis ten należy do serii artykułów o Pytorch, w każdym artykule opisuję pewien aspekt tej biblioteki oraz przykłady najpopularniejszych architektur sieci neuronowych.

Nie wiem jak dla Was, ale dla mnie przetwarzanie tekstu zawsze sprawiało kłopoty. A to kodowanie nie takie, a to trzeba tekst sparsować, nie wspominając o nieszczęsnych regexp. Przy przygotowaniu tekstu do sieci neuronowej dodatkowo dochodzą takie problemy jak: tokenizacja tekstu, budowanie słownika oraz zamiana tekstu na wektor.

Część tych problemów rozwiązuje TorchText. Pomaga on we wczytywaniu zbiorów tekstowych, tworzeniu słownika wyrazów, przekształceniu tekstu na wektor, mapowaniu embedding’ów na słownik oraz dzieleniu tekstu na paczki (batch). We wszystkim tym, co do tej pory było dla mnie pain in the ass (wybaczcie za wyrażenie).

<Update date=”15.08.2020>
Wpis ten staje się już nie aktualny, obecnie TorchText zmiania filozofię funkcjonowania na bardziej spójną z Pytorch i innymi bibliotekami. Nie opieraj swojego kodu na tym co opisuję dalej, szczególnie na klasie Fields
</Update>

We wpisie tym chciałbym przedstawić Wam sam sposób przetwarzania tekstu recenzji filmowych z IMDB. Omówimy na tym przykładzie wczytywanie tekstu, budowę słownika oraz ustawienie embeddingów. Zatrzymamy się na etapie iterowania po paczkach danych (batch). Nie będziemy tworzyć sieci neuronowej. Dokładnie przyjrzymy się kolejnym etapom i głównym klasom tej biblioteki: Fields, Vocab, Dataset, Iterator.

Fields – przepis na analizę tekstu

Cała koncepcja biblioteki TorchText została zbudowana na klasie Field. Służy ona do oznaczenia typu pola z naszego zbioru danych oraz sposobu jego przetwarzania.

Gdy popatrzymy na konstruktor klasy Field, to zobaczymy zestaw argumentów, które określają jak dane oznaczone tym polem mają być przetwarzane. Czy są to dane sekwencyjne, czy nie, jaką mają długość, jak je dzielić na części (tokenizacja) itp.

Chcąc lepiej zrozumieć pojęcie pól (Fields) zastanówcie się w jakim formacie najczęściej dostajecie dane tekstowe. Nie odkryję Ameryki, zakładając, że jest to plik CSV bądź JSON(line). W każdej linii znajduje się jeden egzemplarz treningowy. Na początku mamy etykietę oznaczającą, do jakiej kategorii należy tekst, a po spacji znajduje się dowolny ciąg tekstu (powiedzmy o długości od 50 do 1000 znaków). Pierwsza dana należy do skończonego zbioru etykiet, druga (text) jest sekwencją pewnych tokenów (wyrazów) w wybranym języku. Każdą z nich będziemy inaczej przetwarzać. Etykiety po prostu zamienimy na liczby, natomiast z tekstem będziemy musieli popracować więcej. Rozbić go na słowa, a następnie każde słowo zamienić na liczbę odpowiadającą pozycji w słowniku.

Pola właśnie definiują ujednolicony sposób postępowania z danymi. Poprzez odpowiednie argumenty konstruktora informujemy jak poszczególne pola będzie analizowane. Przyjrzyjmy się najczęściej wykorzystywanym argumentom konstruktora:

class torchtext.data.Field(
    sequential=True, 
    use_vocab=True, 
    init_token=None, 
    eos_token=None, 
    fix_length=None,
    dtype=torch.int64,
    preprocessing=None,
    postprocessing=None,
    lower=False,
    tokenize=None,
    tokenizer_language='en',
    include_lengths=False,
    batch_first=False,
    pad_token='<pad>',
    unk_token='<unk>',
    pad_first=False,
    truncate_first=False,
    stop_words=None,
    is_target=False)
  • sequential – informuje czy to pole będzie określało dane sekwencyjne, najczęściej używane do tekstu.
  • use_vocab – czy będziemy na podstawie wartości oznaczonych tym polem budować słownik. W przypadku tekstu ustawiamy na True, a etykiet najczęściej na False.
  • fix_length – określa czy przeznaczamy stałą ilość miejsca na wektor reprezentujący słowa. Jeżeli tekst jest krótszy to dostawione są znaki paddingu (<pad>), a jeżeli dłuższy to zostaje ucięty.
  • preprocessing – możemy podać własną klasę typu Pipeline, która dla każdego tekstu ze zbioru uruchomi jego wstępne przetworzenie np. dokona lematyzacji lub stemmingu, usunie nazwy własne itp.
  • lower – czy tekst z tego pola ma być przekształcony na małe litery.
  • tokenize – funkcja, która będzie rozbijała naszą sekwencję na tokeny np. na słowa, ngramy, czy inaczej rozumiane części sekwencji. Domyślna wartość to rozbicie po spacji (string.split).
  • pad_token – token używany do wyrównania sekwencji. Domyślnie: “<pad>”.
  • unk_token – token używany do reprezentacji słów OOV(out of vocabulary). Słów, których nie napotkaliśmy w trakcie treningu i budowy słownika. Domyślnie: “<unk>”.
  • stop_words – lista tokenów, które mają zostać pominięte podczas fazy tokenizacji.
  • is_target – pole to służy do wskazania danych będących etykietą, której rozpoznawania mamy się nauczyć.

W naszym przypadku musimy zdefiniować tyle pól ile mamy rodzajów danych. My będziemy przetwarzać recenzje filmowe. Zadanie polega na rozpoznaniu sentymentu recenzji, czy jest pozytywna, czy negatywna. Będziemy musieli zdefiniować dwa pola jedno dla etykiet (’pos’, 'neg’) a drugie dla tekstu recenzji.

Skąd brać dane – TorchText się wszystkim zajmie

TorchText pomoże nam także w pozyskaniu danych. Posiada on klasy dostępu do standardowych zbiorów tekstowych. Nie musimy ich poszukiwać, ściągać i samemu wczytywać. Zbiór z recenzjami IMDB możemy ściągnąć przy pomocy jednej linijki.

train_ds, valid_ds = datasets.IMDB.splits(TEXT, LABEL)

W ten sposób możecie wczytać kilka znanych i lubianych zbiorów z NLP a lista z czasem się powiększa.

Wczytujemy dane – klasa dataset

W przypadku zbioru IMDB to praktycznie nie ma co robić, wszystko dostajemy podane na tacy dzięki TorchText. Natomiast gdybyśmy chcieli wczytać własne dane to mamy dwie możliwości. Skorzystać z uniwersalnej klasy TabularDataset albo dziedziczyć samemu po klasie Dataset.

Klasa TabularDataset pozwala na wczytanie danych czy to w csv, czy w json które mają charakter tabelaryczny.

O napisaniu własnej klasy dataset czytającej z Pandas dataframe napiszę już niedługo, więc pozwólcie, że pominę teraz tę kwestię.

Tak naprawdę to kluczem do zrozumienia datasetów jest klasa Example, gdyż Datasety są praktycznie owijką na listę lub generator Example’i.

Budowa słownika i wczytywanie word embeddings

Mając wczytane źródło danych oraz zdefiniowane Fields(pola) możemy przystąpić do fazy budowy słownika. Oczywiście tutaj mamy ogromne pole do popisu. Bo budowanie słownika jest polityka, każdy wie lepiej jak powinno się go zbudować i nie ma jednej słusznej odpowiedzi. W tym etapie musimy odpowiedzieć sobie na zaj***cie ważne pytania:

  • Jak wielki ma być słownik? Czy ma się składać z wszystkich wyrazów, które napotkamy? Może to skutkować słownikiem wielkości 783225 słów czy ograniczamy liczbę wystąpień do 23451 najbardziej popularnych?
  • Od jakiej częstości występowania dodajemy słowo do słownika? Od 2,5,10 wystąpień czy może od 50?
  • W językach fleksyjnych (np. polskim) dochodzi pytanie, czy lematyzować słowo przed dodaniem, czy nie?
  • No i pytanie klasyk? Co to jest słowo? Czy „Nowy Jork” to jedno, czy dwa słowa? Czyli jak podchodzimy do tokenizacji (podziału na wyrazy).

No dobra a teraz na poważnie. Moje sugestie, od których zaczynam i w większości problemów mi się sprawdziły:

  • zaczynam od ograniczonego słownika: 10000 na początek, rzadko przekraczałem 80000,
  • częstość występowania: min 10 razy (uwaga zależne od ilości tekstu),
  • czy lematyzować: zaczynam bez lematyzacji, bo wykorzystując do polskiego mofologik, wydłuża to czas przetwarzania. Z doświadczenia sprawdzało się, że gdy słownik jest lematyzowany to może być mniejszy, nie lematyzowany większy,
  • tokenizer z NLKT dla języka polskiego jest wystarczający, ale wolny. Mniej dokładny, ale szybszy nltk.tokenize.ToktokTokenizer

Samo budowanie słownika sprowadza się do wywołaniu metody build_vocab wraz z parametrami na polu określającym text.

TEXT.build_vocab(train_ds, min_freq=10, max_size=10000)

Jak wczytać word embeddings?

Gdybyśmy chcieli skorzystać z przeuczonych już word embeddingów (np. word2vec) to możemy to zrobić na dwa sposoby. Pierwszy polega na przekazaniu w wywołaniu metody build_vocab, nazwy jednego z dostępnych modeli (tylko angielski) w TorchText (patrz dokumentacja klasy Vocab).

TEXT.build_vocab(train_ds,min_freq=10, max_size=10000, vectors=GloVe(name='6B', dim=300))

Drugi sposób, bardziej uniwersalny polega na utworzeniu obiektu klasy Vocab i przekazaniu mu nazwy pliku w formacie gensim word2vec (działają nasze z projektu Clarin).

embed_file = 'wiki-forms-all-100-cbow-ns-30-it100.txt'
vec = torchtext.vocab.Vectors(name=embed_file, cache='./data')
TEXT.build_vocab(train_ds, min_freq=10, max_size=10000, vectors=vec)

Nie martwcie się poprawnym mapowaniem słów pomiędzy słowami ze słownika i z modelu word2vec. Torchtext się wszystkim zajmie (sprawdzałem!).

Przechodzimy po danych

Ostatnim etapem, wydawałoby się najprostszym, jest iterowanie po danych. Czy tutaj TorchText może nam coś więcej zaoferować? Otóż tak! Klasa BucketIterator pomoże zgrupować teksty o podobnej długości. Pozwala to na oszczędność pamięci oraz ogranicza puste przebiegi sieci na tokenach, które są już tylko wyrównaniem do wspólnej długości.

Jak wiecie, sieci lubią przetwarzać wszystko będące 'prostokątem’. Gdy załadujemy teksty o różnej ilości tokenów do jednej paczki(batch) to może okazać się, że jeden tekst reprezentowany jest przez tensor długości 10000 a drugi już tylko 3333. Ten drugi zostanie wyrównany do długości 10000 pustymi tokenami.

BucketIterator zanim dołączy teksty do paczki to sprawdza czy są one w tym samym kubełku pod względem określonego kryterium. Najczęściej tym kryterium jest właśnie długość tekstu (określa to parametr sort_key)

train_iter = data.BucketIterator(
    train_ds, batch_size=32, sort_key=lambda x: len(x.text), shuffle=False)

Mając już zdefiniowany iterator nic nie stoi na przeszkodzie, aby przejść po danych w paczkach (batch) w postaci 3-wymiarowych tensorów. Następnie ten tensor można przekazać już na wejście naszej super-hiper głębokiej sieci neuronowej.

Przykład wczytywania i przetwarzania tekstów recenzji z IMDB

Cały powyższy wywód służy tylko temu abyście mogli lepiej zrozumieć poniższy kawałek kodu. W przykładzie poniżej wczytuję zbiór recenzji filmowych z IMDB.

Na początku tworzymy dwa pola TEXT i LABEL. Pole TEXT jest polem, które odpowiada za przetworzenie tekstu a LABEL za dwie etykiety (’pos’, 'neg’).

Wykorzystujemy prosty tokenizer, który dzieli tekst po spacjach. Możecie tutaj podpiąć swój własny ulubiony czy to z biblioteki spacy, czy nltk.

Wczytujemy dataset, już podzielony na zbiór treningowy i walidacyjny (train_ds i valid_ds).

Budujemy słowniki dla dwóch pól TEXT oraz co może zaskakiwać LABEL. Budowa słownika dla pola LABEL wynika z tego, że etykiety określone są tekstowo, a nie np. 0,1.

Ostatnim krokiem jest stworzenie iteratorów oraz pętla, która przechodzi już po danych.
Poniżej cały omawiany kod jako GistGithub TorchText_load_IMDB.py

from torchtext import data
#from torchtext.data import BucketIterator
from torchtext import datasets

def simple_tokinizer(text):
    """ Simple tokenizer
    """
    return text.split()

# set up fields
TEXT = data.Field(lower=True, include_lengths=True, tokenize=simple_tokinizer)
LABEL = data.LabelField()

# it will download dataset automatically :) and make splits for train and validation 
train_ds, valid_ds = datasets.IMDB.splits(TEXT, LABEL)

# show sample of train dataset
example = train_ds[0]
print(example.label)
print(example.text)
print(f'train={len(train_ds)} valid={len(valid_ds)}')

# build the vocabulary
TEXT.build_vocab(train_ds,min_freq=10, max_size=10000 ) #, vectors=GloVe(name='6B', dim=300))
LABEL.build_vocab(train_ds)

print(TEXT.vocab.freqs.most_common(20))
vocab = TEXT.vocab

vocab_size = len(vocab)
print(f'vocab_size={vocab_size}')
print(list(vocab.stoi.keys())[0:20])
print(vocab.itos[0:20])
print(vocab.vectors)

print(LABEL.vocab.stoi)

batch_size = 4
train_iter = data.BucketIterator(
    train_ds, batch_size=batch_size, sort_key=lambda x: len(x.text), sort_within_batch=True)

valid_iter = data.BucketIterator(
    valid_ds, batch_size=batch_size, sort_key=lambda x: len(x.text), sort_within_batch=True)

epoch = 1
# epoch loop
for e in range(epoch):
    for batch_idx, batch in enumerate(train_iter):
        # get text vecotr and label
        batch_text = batch.text[0] # include lengths at [1]
        batch_label = batch.label

        print(batch_text)
        print(batch_label)

        # do what ever you want 
        # .
        # .

Zachęcam was do uruchomienia i eksperymentów oraz prześledzenia przykładu z debbugerem.

Podsumowanie i materiały dodatkowe

Artykuł ten ma na celu zapoznanie i przybliżenie Wam biblioteki TorchText. Pozwala ona uprościć, a także ujednolicić wstępne przetwarzanie tekstu, który chcecie podawać do sieci neuronowej.

Dla osób zainteresowanych przetwarzaniem tekstów w Pytroch polecam materiały:

Jeżeli uważasz ten wpis za wartościowy to Zasubskrybuj bloga. Dostaniesz informacje o nowych artykułach.

Join 100 other subscribers

Photo by Tim Foster on Unsplash

Ciekawe, wartościowe, podziel się proszę opinią!

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.