* Zanurzenia słów ** Słabości $n$-gramowych modeli języka Podstawowa słabość $n$-gramowych modeli języka polega na tym, że każde słowo jest traktowane w izolacji. W, powiedzmy, bigramowym modelu języka każda wartość $P(w_2|w_1)$ jest estymowana osobno, nawet dla — w jakimś sensie podobnych słów. Na przykład: - $P(\mathit{zaszczekał}|\mathit{pies})$, $P(\mathit{zaszczekał}|\mathit{jamnik})$, $P(\mathit{zaszczekał}|\mathit{wilczur})$ są estymowane osobno, - $P(\mathit{zaszczekał}|\mathit{pies})$, $P(\mathit{zamerdał}|\mathit{pies})$, $P(\mathit{ugryzł}|\mathit{pies})$ są estymowane osobno, - dla każdej pary $u$, $v$, gdzie $u$ jest przyimkiem (np. /dla/), a $v$ — osobową formą czasownika (np. /napisał/) model musi się uczyć, że $P(v|u)$ powinno mieć bardzo niską wartość. ** Podobieństwo słów jako sposób na słabości $n$-gramowych modeli języka? Intuicyjnie wydaje się, że potrzebujemy jakiegoś sposobu określania podobieństwa słów, tak aby w naturalny sposób, jeśli słowa $u$ i $u'$ oraz $v$ i $v'$ są bardzo podobne, wówczas $P(u|v) \approx P(u'|v')$. Można wskazać trzy sposoby określania podobieństwa słów: odległość edycyjna Lewensztajna, hierarchie słów i odległość w przestrzeni wielowymiarowej. *** Odległość Lewensztajna Słowo /dom/ ma coś wspólnego z /domem/, /domkiem/, /domostwem/, /domownikami/, /domowym/ i /udomowieniem/ (?? — tu już można mieć wątpliwości). Więc może oprzeć podobieństwa na powierzchownym podobieństwie? Możemy zastosować tutaj *odległość Lewensztajna*, czyli minimalną liczbę operacji edycyjnych, które są potrzebne, aby przekształcić jedno słowo w drugie. Zazwyczaj jako elementarne operacje edycyjne definiuje się: - usunięcie znaku, - dodanie znaku, - zamianu znaku. Na przykład odległość edycyjna między słowami /domkiem/ i /domostwem/ wynosi 4: zamiana /k/ na /o/, /i/ na /s/, dodanie /t/, dodanie /w/. #+BEGIN_SRC python :session mysession :exports both :results raw drawer import Levenshtein Levenshtein.distance('domkiem', 'domostwem') #+END_SRC #+RESULTS: :results: 4 :end: Niestety, to nie jest tak, że słowa są podobne wtedy i tylko wtedy, gdy /wyglądają/ podobnie: - /tapet/ nie ma nic wspólnego z /tapetą/, - słowo /sowa/ nie wygląda jak /ptak/, /puszczyk/, /jastrząb/, /kura/ itd. Powierzchowne podobieństwo słów łączy się zazwyczaj z relacjami *fleksyjnymi* i *słowotwórczymi* (choć też nie zawsze, por. np. pary słów będące przykładem *supletywizmu*: /człowiek/-/ludzie/, /brać/-/zwiąć/, /rok/-/lata/). A co z innymi własnościami wyrazów czy raczej bytów przez nie denotowanych (słowa oznaczające zwierzęta należące do gromady ptaków chcemy traktować jako, w jakiejś mierze przynajmnie, podobne)? Dodajmy jeszcze, że w miejsce odległości Lewensztajna warto czasami używać podobieństwa Jaro-Winklera, które mniejszą wagę przywiązuje do zmian w końcówkach wyrazów: #+BEGIN_SRC python :session mysession :exports both :results raw drawer import Levenshtein Levenshtein.jaro_winkler('domu', 'domowy') Levenshtein.jaro_winkler('domowy', 'maskowy') #+END_SRC #+RESULTS: :results: 0.6626984126984127 :end: *** Klasy i hierarchie słów Innym sposobem określania podobieństwa między słowami jest zdefiniowanie klas słów. Słowa należące do jednej klasy będą podobne, do różnych klas — niepodobne. **** Klasy gramatyczne Klasy mogą odpowiadać standardowym kategoriom gramatycznym znanym z językoznawstwa, na przykład *częściom mowy* (rzeczownik, przymiotnik, czasownik itd.). Wiele jest niejednoznacznych jeśli chodzi o kategorię części mowy: - /powieść/ — rzeczownik czy czasownik? - /komputerowi/ — rzeczownik czy przymiotnik? - /lecz/ — spójnik, czasownik (!) czy rzeczownik (!!)? Oznacza to, że musimy dysponować narzędziem, które pozwala automatycznie, na podstawie kontekstu, tagować tekst częściami mowy (ang. /POS tagger/). Takie narzędzia pozwalają na osiągnięcie wysokiej dokładności, niestety zawsze wprowadzają jakieś błędy, które mogą propagować się dalej. **** Klasy indukowane automatycznie Zamiast z góry zakładać klasy wyrazów można zastosować metody uczenia nienadzorowanego (podobne do analizy skupień) w celu *wyindukowanie* automatycznie klas (tagów) z korpusu. **** Użycie klas słów w modelu języka Najprostszy sposób uwzględnienia klas słów w $n$-gramowym modelowaniu języka polega stworzeniu dwóch osobnych modeli: - tradycyjnego modelu języka $M_W$ operującego na słowach, - modelu języka $M_T$ wyuczonego na klasach słów (czy to częściach mowy, czy klasach wyindukowanych automatycznie). Zauważmy, że rząd modelu $M_T$ ($n_T$) może dużo większy niż rząd modelu $M_W$ ($n_W$) — klas będzie dużo mniej niż wyrazów, więc problem rzadkości danych jest dużo mniejszy i można rozpatrywać dłuższe $n$-gramy. Dwa modele możemy połączyć za pomocą prostej kombinacji liniowej sterowanej hiperparametrem $\lambda$: $$P(w_i|w_{i-n_T}+1\ldots w_{i-1}) = \lambda P_{M_T}(w_i|w_{i-n_W}+1\ldots w_{i-1}) + (1 - \lambda) P_{M_W}(w_i|w_{i-n_T}+1\ldots w_{i-1}).$$ **** Hierarchie słów Zamiast płaskiej klasyfikacji słów można zbudować hierarchię słów czy pojęć. Taka hierarchia może dotyczyć właściwości gramatycznych (na przykład rzeczownik w liczbie pojedynczej w dopełniaczu będzie podklasą rzeczownika) lub własności denotowanych bytów. Niekiedy dość łatwo stworzyć hierarchie (taksonomię) pojęć. Na przykład jamnik jest rodzajem psa (słowo /jamnik/ jest *hiponimem* słowa /pies/, zaś słowo /pies/ hiperonimem słowa /jamnik/), pies — ssaka, ssak — zwierzęcia, zwierzę — organizmu żywego, organizm — bytu materialnego. ***** Analityczny język Johna Wilkinsa Już od dawna filozofowie myśleli o stworzenie języka uniwersalnego, w którym hierarchia bytów jest ułożona w „naturalny” sposób. Przykładem jest angielski uczony John Wilkins (1614-1672). W dziele /An Essay towards a Real Character and a Philosophical Language/ zaproponował on rozbudowaną hierarchię bytów. #+CAPTION: Fragment dzieła Johna Wilkinsa [[./06_Zanurzenia_slow/wilkins.png]] ***** Słowosieci Współczesnym odpowiednik hierarchii Wilkinsa są *słowosieci* (ang. /wordnets). Przykłady: - dla języka polskiego: [[http://plwordnet.pwr.wroc.pl][Słowosieć]], - dla języka angielskiego: [[https://wordnet.princeton.edu/][Princeton Wordnet]] (i Słowosieć!) #+CAPTION: Fragment Słowosieci [[./06_Zanurzenia_slow/slowosiec.png]] W praktyce stosowalność słowosieci okazała się zaskakująco ograniczona. Większy przełom w przetwarzaniu języka naturalnego przyniosły wielowymiarowe reprezentacje słów, inaczej: zanurzenia słów. ** „Wymiary” słów Moglibyśmy zanurzyć (ang. /embed/) w wielowymiarowej przestrzeni, tzn. zdefiniować odwzorowanie $E \colon V \rightarrow \mathcal{R}^m$ dla pewnego $m$ i określić taki sposób estymowania prawdopodobieństw $P(u|v)$, by dla par $E(v)$ i $E(v')$ oraz $E(u)$ i $E(u')$ znajdujących się w pobliżu (według jakiejś metryki odległości, na przykład zwykłej odległości euklidesowej): $$P(u|v) \approx P(u'|v').$$ $E(u)$ nazywamy zanurzeniem (embeddingiem) słowa. *** Wymiary określone z góry? Można by sobie wyobrazić, że $m$ wymiarów mogłoby być z góry określonych przez lingwistę. Wymiary te byłyby związane z typowymi „osiami” rozpatrywanymi w językoznawstwie, na przykład: - czy słowo jest wulgarne, pospolite, potoczne, neutralne czy książkowe? - czy słowo jest archaiczne, wychodzące z użycia czy jest neologizmem? - czy słowo dotyczy kobiet, czy mężczyzn (w sensie rodzaju gramatycznego i/lub socjolingwistycznym)? - czy słowo jest w liczbie pojedynczej czy mnogiej? - czy słowo jest rzeczownikiem czy czasownikiem? - czy słowo jest rdzennym słowem czy zapożyczeniem? - czy słowo jest nazwą czy słowem pospolitym? - czy słowo opisuje konkretną rzecz czy pojęcie abstrakcyjne? - … W praktyce okazało się jednak, że lepiej, żeby komputer uczył się sam możliwych wymiarów — z góry określamy tylko $m$ (liczbę wymiarów). ** Bigramowy model języka oparty na zanurzeniach Zbudujemy teraz najprostszy model język oparty na zanurzeniach. Będzie to właściwie najprostszy *neuronowy model języka*, jako że zbudowany model można traktować jako prostą sieć neuronową. *** Słownik W typowym neuronowym modelu języka rozmiar słownika musi być z góry ograniczony. Zazwyczaj jest to liczba rzędu kilkudziesięciu wyrazów — po prostu będziemy rozpatrywać $|V|$ najczęstszych wyrazów, pozostałe zamienimy na specjalny token ~~ reprezentujący nieznany (/unknown/) wyraz. Aby utworzyć taki słownik użyjemy gotowej klasy ~Vocab~ z pakietu torchtext: #+BEGIN_SRC python :session mysession :exports both :results raw drawer from itertools import islice import regex as re import sys from torchtext.vocab import build_vocab_from_iterator def get_words_from_line(line): line = line.rstrip() yield '' for m in re.finditer(r'[\p{L}0-9\*]+|\p{P}+', line): yield m.group(0).lower() yield '' def get_word_lines_from_file(file_name): with open(file_name, 'r') as fh: for line in fh: yield get_words_from_line(line) vocab_size = 20000 vocab = build_vocab_from_iterator( get_word_lines_from_file('opensubtitlesA.pl.txt'), max_tokens = vocab_size, specials = ['']) vocab['jest'] #+END_SRC #+RESULTS: :results: 16 :end: #+BEGIN_SRC python :session mysession :exports both :results raw drawer len(vocab) #+END_SRC #+RESULTS: :results: 20000 :end: *** Definicja sieci Naszą prostą sieć neuronową zaimplementujemy używając frameworku PyTorch. #+BEGIN_SRC python :session mysession :exports both :results raw drawer from torch import nn import torch embed_size = 100 class SimpleBigramNeuralLanguageModel(nn.Module): def __init__(self, vocabulary_size, embedding_size): super(SimpleBigramNeuralLanguageModel, self).__init__() self.model = nn.Sequential( nn.Embedding(vocabulary_size, embedding_size), nn.Linear(embedding_size, vocabulary_size), nn.Softmax() ) def forward(self, x): return self.model(x) model = SimpleBigramNeuralLanguageModel(vocab_size, embed_size) vocab.set_default_index(vocab['']) ixs = torch.tensor(vocab.forward(['mieszkam', 'w', 'londynie'])) out = model(ixs) out.size() #+END_SRC #+RESULTS: :results: torch.Size([3, 20000]) :end: Teraz wyuczmy model. Wpierw tylko potasujmy nasz plik: #+BEGIN_SRC shuf < opensubtitlesA.pl.txt > opensubtitlesA.pl.shuf.txt #+END_SRC #+BEGIN_SRC python :session mysession :exports both :results raw drawer from torch.utils.data import IterableDataset import itertools def look_ahead_iterator(gen): prev = None for item in gen: if prev is not None: yield (prev, item) prev = item class Bigrams(IterableDataset): def __init__(self, text_file, vocabulary_size): self.vocab = build_vocab_from_iterator( get_word_lines_from_file(text_file), max_tokens = vocabulary_size, specials = ['']) self.vocab.set_default_index(self.vocab['']) self.vocabulary_size = vocabulary_size self.text_file = text_file def __iter__(self): return look_ahead_iterator( (self.vocab[t] for t in itertools.chain.from_iterable(get_word_lines_from_file(self.text_file)))) train_dataset = Bigrams('opensubtitlesA.pl.shuf.txt', vocab_size) #+END_SRC #+RESULTS: :results: :end: #+BEGIN_SRC python :session mysession :exports both :results raw drawer from torch.utils.data import DataLoader next(iter(train_dataset)) #+END_SRC #+RESULTS: :results: (2, 19922) :end: #+BEGIN_SRC python :session mysession :exports both :results raw drawer from torch.utils.data import DataLoader next(iter(DataLoader(train_dataset, batch_size=5))) #+END_SRC #+RESULTS: :results: [tensor([ 2, 19922, 114, 888, 1152]), tensor([19922, 114, 888, 1152, 3])] :end: #+BEGIN_SRC python :session mysession :exports both :results raw drawer device = 'cuda' model = SimpleBigramNeuralLanguageModel(vocab_size, embed_size).to(device) data = DataLoader(train_dataset, batch_size=8000) optimizer = torch.optim.Adam(model.parameters()) criterion = torch.nn.NLLLoss() model.train() step = 0 for x, y in data: x = x.to(device) y = y.to(device) optimizer.zero_grad() ypredicted = model(x) loss = criterion(torch.log(ypredicted), y) if step % 100 == 0: print(step, loss) step += 1 loss.backward() optimizer.step() torch.save(model.state_dict(), 'model1.bin') #+END_SRC #+RESULTS: :results: None :end: #+BEGIN_SRC python :session mysession :exports both :results raw drawer vocab = train_dataset.vocab ixs = torch.tensor(vocab.forward(['jest', 'mieszkam', 'w', 'londynie'])).to(device) out = model(ixs) top = torch.topk(out[0], 10) top_indices = top.indices.tolist() top_probs = top.values.tolist() top_words = vocab.lookup_tokens(top_indices) list(zip(top_words, top_indices, top_probs)) #+END_SRC #+RESULTS: :results: [('jorku', 1079, 0.41101229190826416), ('.', 3, 0.07469522953033447), ('', 0, 0.04370327666401863), (',', 4, 0.023186953738331795), ('...', 15, 0.0091575738042593), ('?', 6, 0.008711819536983967), ('tym', 30, 0.0047738500870764256), ('to', 7, 0.004259662237018347), ('do', 17, 0.004140778910368681), ('w', 10, 0.003930391278117895)] :end: