aitech-moj/wyk/06_Zanurzenia_slow.org
2022-04-11 10:41:35 +02:00

15 KiB

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.

  import Levenshtein
  Levenshtein.distance('domkiem', 'domostwem')

4

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:

  import Levenshtein
  Levenshtein.jaro_winkler('domu', 'domowy')
  Levenshtein.jaro_winkler('domowy', 'maskowy')

0.6626984126984127

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.

/filipg/aitech-moj/media/commit/78d203d8c9e1e088d0c7ee8c3f4679f589bcfdaf/wyk/06_Zanurzenia_slow/wilkins.png
Fragment dzieła Johna Wilkinsa
Słowosieci

Współczesnym odpowiednik hierarchii Wilkinsa są słowosieci (ang. /wordnets). Przykłady:

/filipg/aitech-moj/media/commit/78d203d8c9e1e088d0c7ee8c3f4679f589bcfdaf/wyk/06_Zanurzenia_slow/slowosiec.png
Fragment Słowosieci

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 <unk> reprezentujący nieznany (unknown) wyraz.

Aby utworzyć taki słownik użyjemy gotowej klasy Vocab z pakietu torchtext:

  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 '<s>'
    for m in re.finditer(r'[\p{L}0-9\*]+|\p{P}+', line):
       yield m.group(0).lower()
    yield '</s>'


  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 = ['<unk>'])

  vocab['jest']

16

vocab.lookup_tokens([0, 1, 2, 10, 12345])

['<unk>', '</s>', '<s>', 'w', 'wierzyli']

Definicja sieci

Naszą prostą sieć neuronową zaimplementujemy używając frameworku PyTorch.

  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['<unk>'])
  ixs = torch.tensor(vocab.forward(['pies']))
  out[0][vocab['jest']]

Teraz wyuczmy model. Wpierw tylko potasujmy nasz plik:

shuf < opensubtitlesA.pl.txt > opensubtitlesA.pl.shuf.txt
  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 = ['<unk>'])
        self.vocab.set_default_index(self.vocab['<unk>'])
        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)
  from torch.utils.data import DataLoader

  next(iter(train_dataset))

(2, 5)

  from torch.utils.data import DataLoader

  next(iter(DataLoader(train_dataset, batch_size=5)))

[tensor([ 2, 5, 51, 3481, 231]), tensor([ 5, 51, 3481, 231, 4])]

  device = 'cuda'
  model = SimpleBigramNeuralLanguageModel(vocab_size, embed_size).to(device)
  data = DataLoader(train_dataset, batch_size=5000)
  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')

None

Policzmy najbardziej prawdopodobne kontynuację dla zadanego słowa:

  device = 'cuda'
  model = SimpleBigramNeuralLanguageModel(vocab_size, embed_size).to(device)
  model.load_state_dict(torch.load('model1.bin'))
  model.eval()

  ixs = torch.tensor(vocab.forward(['dla'])).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))

[('ciebie', 73, 0.1580502986907959), ('mnie', 26, 0.15395283699035645), ('<unk>', 0, 0.12862136960029602), ('nas', 83, 0.0410110242664814), ('niego', 172, 0.03281523287296295), ('niej', 245, 0.02104802615940571), ('siebie', 181, 0.020788608118891716), ('którego', 365, 0.019379809498786926), ('was', 162, 0.013852755539119244), ('wszystkich', 235, 0.01381855271756649)]

Teraz zbadajmy najbardziej podobne zanurzenia dla zadanego słowa:

  vocab = train_dataset.vocab
  ixs = torch.tensor(vocab.forward(['kłopot'])).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))

[('.', 3, 0.404473215341568), (',', 4, 0.14222915470600128), ('z', 14, 0.10945753753185272), ('?', 6, 0.09583134204149246), ('w', 10, 0.050338443368673325), ('na', 12, 0.020703863352537155), ('i', 11, 0.016762692481279373), ('<unk>', 0, 0.014571071602404118), ('…', 15, 0.01453721895813942), ('</s>', 1, 0.011769450269639492)]

  cos = nn.CosineSimilarity(dim=1, eps=1e-6)

  embeddings = model.model[0].weight

  vec = embeddings[vocab['poszedł']]

  similarities = cos(vec, embeddings)

  top = torch.topk(similarities, 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))

[('poszedł', 1087, 1.0), ('idziesz', 1050, 0.4907470941543579), ('przyjeżdża', 4920, 0.45242372155189514), ('pojechałam', 12784, 0.4342481195926666), ('wrócił', 1023, 0.431664377450943), ('dobrać', 10351, 0.4312002956867218), ('stałeś', 5738, 0.4258835017681122), ('poszła', 1563, 0.41979148983955383), ('trafiłam', 18857, 0.4109022617340088), ('jedzie', 1674, 0.4091658890247345)]