ML: N-грами


Вступ

Цей документ є продовженням обговорення імовірнісних методів у машинному навчанні. Ми розглянемо класичний і такий, що не втратив свого значення, метод n-грам для прогнозування послідовностей. Як приклад таких послідовностей будуть розглянуті букви і слова текстів природною мовою. Однак, цей же підхід з невеликими модифікаціями можна використовувати при аналізі часових рядів і в інших задачах.

Наведені далі приклади на Python використовують бібліотеку nltk (Natural Language Toolkit) і можуть бути знайдені у файлі ML_NGrams.ipynb. Розглянутий також суттєво швидший модуль my_ngrams.py, заснований на деревоподібному представленні n-грам.


nltk: Словник

Нехай проведена токенізація послідовності (вона розбита на елементарні символи). Як токени можуть виступати букви, слова природної мови або номери інтервалів зміни дійсної величини (при аналізі часових рядів). Щоб створити словник усіх можливих токенів, у nltk їхній список text передається екземпляру класу Vocabulary:

    
from nltk.lm import Vocabulary

text = ['a', 'b', 'r', 'a', 'c', 'a', 'd', 'a', 'b', 'r', 'a']
vocab = Vocabulary(text, unk_cutoff=2)
Клас Vocabulary є надбудовою над стандартним класом Counter(), екземпляр якого знаходиться в vocab.counts. Він складає частотний словник. Параметр unk_cutoff задає поріг для рідкісних слів. Якщо слово в списку зустрічається менше unk_cutoff разів, то воно замінюється на службове слово '<UNK>' (невідоме).

Словник пам'ятає всі токени (vocab[t] повертає число появ токена t у списку). При цьому конструкція
t in vocab повертає True тільки для токенів з частотою вищою або рівною порогу unk_cutoff:

    
print(vocab['a'], 'a' in vocab)      # 5  True   зустрівся 5 разів, потрапив у словник
print(vocab['c'], 'c' in vocab)      # 1  False  не потрапив у словник (1 < unk_cutoff=2)
print(vocab['Z'], 'Z' in vocab)      # 0  False  такого взагалі не було 
Текст для словника можна додавати довільне число разів:
    
vocab.update(['c', 'c'])             # додали слів
print(vocab['c'], 'c' in vocab)      # 3  True перевищує поріг cutoff
Інформацію про словник, список слів (включно з <UNK>) і список токенів без урахування порогу, можна отримати таким чином:
    
print(vocab)                         # Vocabulary cutoff=2 unk_label='<UNK>' and 5 items
len(vocab)                           # 5 число слів включаючи токен '<UNK>'

sorted(vocab)                        # ['<UNK>', 'a', 'b', 'c', 'r']   що вважає словником
sorted(vocab.counts)                 # ['a', 'b', 'c', 'd', 'r']       все, що отримав

[ (v, vocab[v])  for v in vocab]     # [('a',5), ('b',2), ('r',2), ('c',3), ('<UNK>',2)]
За допомогою методу lookup у будь-якому списку (включно з вкладеними списками) можна перевірити слова на наявність їх у словнику і незнайомі (або рідкісні) замінити на '<UNK>'
    
vocab.lookup('a')                    # 'a'
vocab.lookup(['a', 'aliens'])        # ('a', '<UNK>')
vocab.lookup(['a', 'b', ['Z', 'b']]) # ('a', 'b', ('<UNK>', 'b'))


nltk: N-грами

Послідовність n токенів у тексті називається n-грамою. У nltk функція ngrams повертає генератор на список усіх n-грам. Для випадку n=2 служить функція bigrams. Так, для списку цілих чисел [1...5] отримаємо всі 3-грами і 2-грами:

    
from nltk.util import ngrams, bigrams

list( ngrams ([1,2,3,4,5], 3) )      # [(1, 2, 3), (2, 3, 4), (3, 4, 5)]    
list( bigrams([1,2,3,4,5])  )        # [(1, 2), (2, 3), (3, 4), (4, 5)]  
Підкреслимо, що функції повертають генератори. Тому, якщо написати bg = bigrams([1,2,3,4,5]), а потім двічі викликати list(bg), то другий виклик поверне порожній список (генератор відпрацює в першому виклику).

При обробці природної мови, тексти розбиваються на речення. У nltk прийнято оточувати речення токенами <s> ...</s>. Для виконання цієї процедури існує набір функцій препроцесингу. Вони також повертають генератори, тому є "одноразовими":

    
from nltk.lm.preprocessing import pad_both_ends, padded_everygram_pipeline

list( pad_both_ends(['a','b'], n=2) ) ) # ['<s>', 'a', 'b', '</s>']
list( pad_both_ends(['a','b'], n=3) ) ) # ['<s>', '<s>', 'a', 'b', '</s>', '</s>']
Параметр n вказує для яких n-грам "потрібне" таке "забивання":
    
list( bigrams( pad_both_ends(['a','b'], n=2) ) ) # [('<s>','a'), ('a','b'), ('b','</s>')]
Використовувати значення n > 2 або взагалі відмовитися від падінгу (n=1) залежить від конкретної задачі.

За допомогою функції padded_everygram_pipeline можна підготувати список речень для роботи з n-грамами:

    
sents = [['a', 'b'], ['b', 'a'] ]                  # 2 "речення"
train, vocab = padded_everygram_pipeline(2, sents) 

print( list(vocab) )         # ['<s>', 'a', 'b', '</s>', '<s>', 'b', 'a', '</s>']
Залежно від задачі, "речення" можуть бути як реальними реченнями тексту, так і окремими документами. Слід пам'ятати, що при обчисленні умовних ймовірностей (див. нижче) n-грами будуть складатися всередині кожного "речення" без їхнього перетину між "реченнями".

Сенс використання забивок полягає в можливості отримання ймовірності першого, другого і т.д. слова в реченні. Наприклад для триграм, якщо "<s> <s> мама мыла раму </s> </s>", то ймовірність першого слова дорівнює P(<s> <s> => мама).

Зазначимо також, що, як і раніше, padded_everygram_pipeline повертає "одноразові" генератори, тому на практиці вони одразу передаються в наступну ланку обробки тексту. Крім цього, перший аргумент padded_everygram_pipeline повинен, незалежно від падінгу, дорівнювати числу n-грам, інакше буде отримано невірний генератор train.


nltk: Умовні ймовірності

Нагадаємо, що імовірнісна мовна модель для даного слова $w$, за його контекстом $\{\mathcal{T} - w\}$ (текст $\mathcal{T}$ без слова $w$) передбачає ймовірність цього слова: $P(w|\mathcal{T} - w)$. Зазвичай, як контекст використовуються слова, що йдуть перед $w$. Якщо відома умовна ймовірність $ P( w_n\,|\, w_1...w_{n-1})\equiv P(w_1...w_{n-1}\Rightarrow w_n)$ того, що після ланцюжка слів (токенів) $w_1...w_{n-1}$ йде слово $w_n$, то вибравши найбільшу ймовірність, можна передбачити це слово. Оцінка таких умовних ймовірностей проводиться за допомогою частотного аналізу появи в тексті n-грам $(w_1,...,w_{n})$.

Відповідні підрахунки в nltk здійснюються сімейством мовних моделей. Розглянемо спочатку клас MLE (Maximum Likelihood Estimator):

    
from nltk.lm import MLE

sents = [['a','b','c'], ['b', 'a'] ]      # 2 "речення"
train, vocab = padded_everygram_pipeline(n, sents)
n = 2
lm = MLE(n)                               # працюємо з біграмами
lm.fit(train, vocab)                      # навчаємося (рахуємо ймовірності)
Генератор vocab містить весь текст з об'єднаними реченнями (оточеними символами <s>, </s>). Він використовується для складання словника. У train знаходиться генератори n-грам для кожного речення. Складання n-грам проводиться окремо для кожного речення. Тому, незалежно від наявності забивок <s>, </s>, n-грами для 1,2,...,n між реченнями не перетинаються.

Оскільки для великих текстів функція fit працює достатньо повільно, бажано виводити процес її роботи. Наприклад, нехай є одне речення зі списку слів: words і нас цікавлять ймовірності n-грам (для n=1,...,n). Тоді модель можна навчити таким чином:

    
lm = MLE(n)         
lm.fit( [ ngrams(words,1) ], words)    # юніграми і словник
for i in range(2, n+1):
    lm.fit( [ ngrams(words, i) ] )     # наступні n-грами
    print(f"n:{i:2d}> ngrams:{lm.counts.N():10d}")

Після навчання (метод fit) можна вивести число запам'ятованих n-грам і словник (попередній приклад):

    
print(lm.counts.N())                      # 16 число n-грам, n=1,2 
print(sorted(lm.vocab) )                  # ['</s>', '<UNK>', '<s>', 'a', 'b', 'c']
Число появ у тексті і ймовірність одиничного символу повертає об'єкт counts і метод score:
    
print(lm.counts['a'], lm.score('a'))      # 2 0.222 = 2/9 (з урахуванням символів '</s>''<s>')
Число 2-грам і умовну ймовірність P(a => b) отримуємо так:
    
print(lm.counts[['a']]['b'], lm.score('b', ['a']))  #  1 0.5
Дійсно, в тексті є дві біграми, що починаються з 'a': ('a','b') і ('a','</s>'), де друга - в кінці другого речення. Тому P(a => b) = 1/2. У загальному випадку, якщо contex=$[w_1,...,w_{n-1}]$, то число n-грам дорівнює lm.counts[context][$w_n$], а умовна ймовірність $P(w_1...w_{n-1}\Rightarrow w_n)$ дорівнює lm.score($w_n$, context).


nltk: Ентропія і перплексія

Знаючи умовні ймовірності, можна обчислити перплексію тестового тексту, який необхідно попередньо розбити на n-грами:

 
test = [('a', 'b'), ('b', 'a'), ('a', 'b')]

print("e=", lm.entropy(test) )                           # 1.0
print("p=", lm.perplexity(test), 2**lm.entropy(test) )   # 2.0   2.0

print( [ lm.score(b[-1], b[:-1] )  for b in test] )      # [0.5, 0.5, 0.5]
Ентропія $H$ (з логарифмом за основою 2), а потім перплексія $\mathcal=2^H$ обчислюється таким чином:
 
sum( [ -lm.logscore(ng[-1], ng[:-1]) for ng in  test] )/len(test)
де lm.logscore дорівнює двійковому логарифму від lm.score.


Проблеми великих словників

Якщо в марковських моделях мови, як елементарні символи виступають слова, то розмір словника стає дуже великим. Це призводить до таких проблем:

З проблемою OOV можна боротися таким чином. Рідкісні слова в навчальному корпусі одразу замінюються на одне слово UNK (unknown). Відповідно, умовні ймовірності будуть містити це службове слово і більш-менш справлятися з незнайомими словами (також замінюваними на UNK) при тестуванні моделі. Наведемо приклад:

 
sents = [['a', 'b', 'c'], ['b', 'a'] ]        # 2 "речення"

vocab = Vocabulary(unk_cutoff=2)
for s in sents: 
    vocab.update(s)
sents = vocab.lookup(sents)                   # (('a', 'b', '<UNK>'), ('b', 'a'))

train, vocab = padded_everygram_pipeline(2, sents)

lm = MLE(2)
lm.fit(train, vocab)

print( lm.score('<UNK>'))                     # 0.11111 = 1/9
print( lm.score('<UNK>',  ['b'] ) )           # 0.5 = P(b => <UNK>)

Поява нульових ймовірностей, що не дозволяють обчислити ентропію і перплексію, розглянемо на такому тренувальному корпусі: ('a', 'b', 'c', 'b', 'a'). Біграми ('a','c') в цьому корпусі немає. Якщо вважати, що P('a' => 'c') = 0, то при тестуванні отримаємо нульову ймовірність і нескінченні ентропію і перплексію:
 
test = [('a', 'b'), ('a', 'c')]

print(lm.score('c', ['a']))                   # 0.0   P( a => c)
print(lm.entropy(test) )                      # inf
Взагалі, якщо оцінювати умовну ймовірність за формулою: $$ P(w_1...w_{n-1}\Rightarrow w_n) = \frac{N(w_1...w_n)}{N(w_1...w_{n-1})}, $$ то для довгих n-грам нулю може дорівнювати, як чисельник $N(w_1...w_n)$, так і знаменник $N(w_1...w_{n-1})$. Крім цього, при малих значеннях $N(w_1...w_{n-1})$ оцінка емпіричної умовної ймовірності стає дуже ненадійною.

Згладжування, відкат і інтерполяція

Проблему нульових умовних ймовірностей можна вирішувати різними способами. Найпростіший і грубий - це зробити нульові ймовірності маленькими, але ненульовими. Для цього використовують згладжування Lidstone, окремий випадок якого ($\gamma=1$) називається згладжуванням Лапласа $$ \tilde{P}(w_1,...,w_{n-1}\Rightarrow w_n) = \frac{ N(w_1...w_n) + \gamma}{N(w_1...w_{n-1})+|\mathcal{V}|\cdot\gamma}, $$ де $N(...)$ - число появи послідовностей слів у тексті, а $+|\mathcal{V}|$ - число слів у словнику. Неважко побачити, що сума $\tilde{P}$ за всіма $w_n\in \mathcal{V}$ дорівнює одиниці. Зазначимо, що, якщо в навчальному корпусі немає n-грами $(w_1...w_{n-1})$, то ймовірності всіх слів словника будуть дорівнювати $1/|\mathcal{V}|$.

Інший спосіб - це відкат (backoff). Так, якщо, наприклад, $P(w_1,w_2 \Rightarrow w_3)$ дорівнює нулю (3-грама $w_1w_2w_3$ не зустрілася в навчальному корпусі), то беруть ймовірність $P(w_2 \Rightarrow w_3)$. Якщо і вона дорівнює нулю, то просто $P(w_3)$. Такий метод вимагає додаткового нормування, оскільки сума отримуваних ймовірностей за всіма словами словника, взагалі кажучи, не дорівнює 1. Відповідно, перплексія буде обчислена невірно. Існують різні варіації backoff-стратегії, що усувають цю проблему.

Достатньо простий спосіб боротьби з нульовими ймовірностями - це інтерполяція, за допомогою якої умовну ймовірність обчислюють таким чином: $$ \tilde{P}(w_1...w_{n-1}\Rightarrow w_n) = \frac{ P(w_1...w_n\Rightarrow w) +\lambda_1\, P(w_2...w_{n-1}\Rightarrow w) +... + \lambda_n\,P(w) } { 1+\lambda_1+...+\lambda_n }. $$ Коефіцієнти $\lambda_1,...\lambda_n$ повинні спадати (більшу вагу дають довші умовні ймовірності). Однак, якщо вони дорівнюють нулю, інформація про ймовірність символу $w_n$ буде отримана з коротшої умовної ймовірності і $\tilde{P}$ ніколи не буде нульовою. Наприклад, можна вибрати $\lambda_k=\beta^k$, де $\beta \in [0...1]$ - параметр моделі. Формально, сума цього виразу за всіма $w$ зі словника дорівнює одиниці (завдяки знаменнику). Однак, це буде так, тільки, якщо $P(w_{n-k}...w_{n-1} \Rightarrow w_n)$ відмінна від нуля хоча б для одного $w_n$. Тому, і в чисельнику, і в знаменнику слід прибирати перші доданки для $P(w_{1}...w_{k} \Rightarrow w)$ у яких $N(w_{1}...w_{k})=0$ (тобто $P=0$ для всіх слів словника).

Можна також ліквідувати доданки з малим значенням $N(w_{1}...w_{k})$ (оскільки вони дають ненадійне значення ймовірності) або враховувати у вагах похибку виміру ймовірності.


nltk: Мовні моделі

Методи боротьби з нульовими умовними ймовірностями реалізовані в nltk як різні мовні моделі. Вони мають однотипний інтерфейс, але різні алгоритми обчислення функції score. Розглянута вище модель MLE для умовної ймовірності просто повертає N(w_1...w_n)/N(w_1...w_{n-1}) Інші моделі мають такі назви:

Неважко створювати власні мовні моделі, успадковуючи їх від класу LanguageModel і перевизначаючи метод unmasked_score, який повинен повертати умовну ймовірність. Наприклад, проста інтерполяція з параметром $\beta$, описана в попередньому розділі, має таку реалізацію:
class Interpolated(LanguageModel):

    def __init__(self, beta, *args, **kwargs):
        super().__init__(*args, **kwargs)       
        self.beta = beta

    def unmasked_score(self, word, context=None):
        if not context:
            counts = self.context_counts(context)
            return counts[word] / counts.N() if counts.N() > 0 else 0
    
        prob, norm, coef = 0, 0, 1;
        for i in range(len(context)+1):    
            counts = self.context_counts(context[i:])            
            if counts.N() > 0:
                prob += coef * ( counts[word] / counts.N() )
                norm += coef                   
            coef  *= self.beta                      
        return prob/norm                       
Метод unmasked_score викликається в методі score після заміни незнайомих токенів на <UNK> (за допомогою функції lookup).

Наведемо приклади обчислення біграмних умовних ймовірностей і їхньої суми за допомогою різних методів:
MLE(2)           [0.0,   0.0,   0.5,   0.0,   0.5  ]   1.0
Laplace(2)       [0.125, 0.125, 0.25,  0.125, 0.25 ]   0.875
Lidstone(0.5, 2) [0.1,   0.1,   0.3,   0.1,   0.3  ]   0.900
KneserNey(2)     [0.016, 0.016, 0.466, 0.016, 0.466]   0.983
WittenBell(2)    [0.049, 0.049, 0.438, 0.024, 0.438]   1.0

Smooth(0.5, 2)   [0.074, 0.074, 0.407, 0.037, 0.407]   1.0
Backoff(2)       [0.222, 0.222, 0.5,   0.111, 0.5  ]   1.555
Звернемо увагу на останній рядок, у якому представлено результат наївного відкату backoff, який призводить до завищених ймовірностей, сума яких більша за одиницю.

nltk: Генерація тексту

Дану мовну модель можна використовувати для генерації тексту. Для цього служить метод generate, який може почати генерацію з випадкового слова або затравочного тексту text_seed (списку початкових слів):
lm.generate(5, random_seed=3)                     # ['<s>', 'b', 'a', 'b', 'c']
lm.generate(4, text_seed=['<s>'], random_seed=3)  # ['a', 'b', 'a', 'b']

Метод generate для отримання чергового слова бере список text_seed (або накопичений при генерації текст), відрізає від нього останні (n = lm.order) - 1 символів (контекст). Потім шукає слова, які в тестовій послідовності могли слідувати за цим контекстом. Якщо таких слів немає, контекст укорочується. Ймовірності таких слів далі обчислюються "чесно" в рамках даної мовної моделі.


Статистичні властивості російської мови

Розглянемо 33 букви в текстах російської мови з алфавітом: "_абвгдежзийклмнопрстуфхцчшщъыьэюя", переводячи текст у нижній регістр, замінюючи 'ё' на 'e' і "неалфавітні" символи на пробіли (підкреслення _ - це пробіл). Потім усунемо кілька поспіль пробілів. Ймовірності окремих букв за текстом довжини N=8'343'164 дорівнюють:

'_': 0.1641,   'с': 0.0437,    'у': 0.0238,     'б': 0.0143,     'э': 0.0030,
'о': 0.0948,   'л': 0.0407,    'п': 0.0234,     'ч': 0.0126,     'ц': 0.0030,
'е': 0.0701,   'р': 0.0371,    'я': 0.0182,     'й': 0.0093,     'щ': 0.0029,
'а': 0.0685,   'в': 0.0368,    'ь': 0.0159,     'ж': 0.0084,     'ф': 0.0017,
'и': 0.0554,   'к': 0.0290,    'ы': 0.0155,     'ш': 0.0071,     'ъ': 0.0003
'н': 0.0551,   'м': 0.0264,    'г': 0.0152,     'х': 0.0070,
'т': 0.0516,   'д': 0.0259,    'з': 0.0144,     'ю': 0.0049,

Перплексія $e^H$ російського тексту за ймовірностями поодиноких букв дорівнює: 20.7 ($\ln H=3.03$, $\log_2H = 4.37$).

Умовні і спільні ймовірності глибини два, відсортовані за спаданням умовних ймовірностей (перша цифра $P(\mathbf{э}\Rightarrow\mathbf{т})=0.83$, друга - $P(\mathbf{э},\mathbf{т})=0.0025$):

эт,0.83, 0.0025   ще,0.52, 0.0015   ъя,0.41, 0.0001   ы_,0.29, 0.0045   пр,0.27, 0.0064   
й_,0.79, 0.0074   го,0.50, 0.0076   по,0.40, 0.0093   м_,0.29, 0.0076   щи,0.27, 0.0008   
я_,0.63, 0.0114   ъе,0.45, 0.0001   за,0.36, 0.0052   че,0.28, 0.0035   ко,0.27, 0.0078   
ь_,0.62, 0.0098   х_,0.45, 0.0031   и_,0.32, 0.0176   ше,0.28, 0.0020   то,0.27, 0.0138   
ю_,0.53, 0.0026   же,0.41, 0.0035   хо,0.30, 0.0021   у_,0.28, 0.0066   е_,0.26, 0.0180   

Для 3-грам аналогічно, але з фільтром за спільними ймовірностями $P(x_1,x_2,x_3) > 0.001$:

 ые_, 0.98, 0.0011    ся_, 0.91, 0.0032    ее_, 0.84, 0.0011    их_, 0.79, 0.0012      
 ый_, 0.98, 0.0015    ий_, 0.89, 0.0011    ть_, 0.83, 0.0046    ия_, 0.78, 0.0011     
 лся, 0.97, 0.0014     я_, 0.86, 0.0022    ой_, 0.83, 0.0030    му_, 0.77, 0.0016     
 что, 0.95, 0.0030     эт, 0.85, 0.0024    его, 0.83, 0.0025    ие_, 0.75, 0.0016     
 сь_, 0.95, 0.0025    ая_, 0.84, 0.0017    это, 0.81, 0.0020    ей_, 0.71, 0.0014     
 ет_, 0.46, 0.0021
Для 4-грам з фільтром $P(x_1,x_2,x_3,x_4) > 0.0001$
огда, 1.00, 0.0006   рый_, 1.00, 0.0002   _ее_, 1.00, 0.0004   ные_, 1.00, 0.0006
оей_, 1.00, 0.0002   рые_, 1.00, 0.0002   _ей_, 1.00, 0.0001   ный_, 1.00, 0.0007
оего, 1.00, 0.0001   сех_, 1.00, 0.0001   ках_, 1.00, 0.0001
оих_, 1.00, 0.0001   _кто, 1.00, 0.0003   ных_, 1.00, 0.0004
Перплексія достатньо швидко насичується і перестає зменшуватися (len(text_trn)=6'678'346, len(text_tst)=1'664'818, методи див. в додатку і my_ngrams.py):
        Laplas(1,order)  Interpolated(beta, minN=1)          Interpolated(beta, minN=3)
order                      0.1       0.5      0.8               0.2      0.5    0.8
----------------------------------------------------------------------------------------
 1       20.74           20.74     20.74    20.74               20.74   20.74   20.74
 2       12.04           12.25     13.27    13.92               12.51   13.27   13.92
 3        8.25            8.33      9.24    10.11                8.50    9.24   10.11
 4        5.85            5.79      6.52     7.43                5.91    6.52    7.44
 5        5.00            4.59      5.01     5.83                4.62    5.02    5.84
 6        5.49            4.39      4.34     4.98                4.23    4.35    5.01
 7        7.17            4.95      4.12     4.53                4.31    4.11    4.58
 8        9.94            6.20      4.16     4.30                4.61    4.07    4.35
 9                                  4.33     4.19                4.99    4.11    4.25
10                                  4.56     4.16                5.36    4.18    4.20
11                                  4.81     4.16                        4.26    4.18
12                                  5.04     4.17                        4.32    4.18
------------------------------------------------------------------------------------------
best      5.00           4.39       4.12     4.16               4.23     4.07    4.18

Генерація тексту за затравочною фразою "три девицы под окном пряли поздно вечерко" (нижче повторюємо "вечерко" і використовуємо інтерполяцію з $\beta=0.5$):

1 вечеркоелшыго сво гшо ддлтготуэут ттм уи е т ьгевокбт кы жслиотепяниэ дпжевткк нотго вдат на кытен ауьд 
2 вечеркогогл ауек кинуге дис уко т номотме виешеызл пряь анй   верилере альо овокалиср блччл  фричридаелю 
3 вечеркообойыхоложе жал с илсяузна эти сти тсково кахопнодто слыекеть наня мот сквексар ни о внылсяарус 
4 вечеркокончистоотвалиамекам отается дерсталовом пе я назы и ги  сел такогдая что полегкомордиямоглазавшна 
5 вечерконечноговя потоненным что стало человам по как что мая тичесили глу приваннытные готовый фарее 
6 вечерком состоинственно он не круглой не было и правительно бы чепухую зервы отдадоставая разбогат я 
7 вечерком по возвращаясь ко торчащегося обратом городаряла креслу отправятся сказала анна мира а о с 
8 вечерком спасибо комов ну и что на паука одуванчик способно расправах вот загременно перемену на часы 
9 вечерком главная самое не будет но если согласится а помогать ими и очень и очень доволен и наобороны 


Для 28 англійських букв _'abcdefghijklmnopqrstuvwxyz (плюс пробіл і апостроф)
_ 0.1957,   i 0.0498,   u 0.0189,   k 0.0100,
e 0.1055,   s 0.0486,   m 0.0188,   v 0.0072,
t 0.0725,   r 0.0451,   g 0.0179,   j 0.0025,
a 0.0679,   d 0.0415,   y 0.0163,   ' 0.0022,
o 0.0592,   l 0.0311,   f 0.0157,   x 0.0012,
h 0.0533,   w 0.0215,   p 0.0140,   z 0.0008,
n 0.0501,   c 0.0196,   b 0.0124,   q 0.0004
перплексія дорівнює 17.07. Для MarkovInterpolated(beta=0.5, minN = 3) при order = 9 перплексія знижується до 2.97len(text_trn)=17'246'125 і len(text_tst)=4'696'973).

Рівень слів

Найпопулярніші слова в ipm (instances per million words для "корпусу" довжиною 1'097'544 слів).

   и: 40571      за: 4516      мы: 2458   теперь: 1542       раз: 1148      этом: 922
   в: 23556      из: 4439     для: 2205     была: 1457        во: 1138       вам: 910
  не: 23270      же: 3894     нет: 2193   ничего: 1453      тоже: 1115      всех: 910
 что: 16062     она: 3835     уже: 2184      чем: 1446      один: 1098       тем: 892
  на: 15092  сказал: 3828      ну: 2183     были: 1445     здесь: 1096        ей: 885
   я: 13387      от: 3565   когда: 2023     того: 1442       том: 1083       сам: 885
  он: 11503     еще: 3417    если: 1977     быть: 1433     после: 1070   которые: 872
   с: 11490     мне: 3396      до: 1944    будет: 1419    потому: 1066     тогда: 872
  то:  8884      бы: 3343     или: 1908    этого: 1416      тебе: 1061       про: 867
   а:  8884      ты: 3277    него: 1828      тут: 1353       вас: 1055    больше: 867
 как:  7987    меня: 3231      ни: 1776    время: 1316        со: 1050   сказала: 857
 это:  7628      да: 3210    есть: 1776      где: 1305      чего: 1035       нам: 844
 все:  6730  только: 3164    даже: 1720     себя: 1298      тебя: 1021       ним: 840
  но:  6510       о: 3048      их: 1714    потом: 1297      дело: 1014     через: 836
 его:  5999     был: 2874     там: 1709       ли: 1284   человек: 1009   спросил: 834
   к:  5932     вот: 2717     кто: 1707      под: 1263   который:  993       тот: 817
  по:  5455      вы: 2632   очень: 1698     себе: 1235    сейчас:  985     можно: 816
   у:  4948     они: 2552   чтобы: 1690      нас: 1234    просто:  971     глаза: 796
 так:  4815      ее: 2508    надо: 1599     этот: 1226       при:  951    нибудь: 785
было:  4580     ему: 2507   может: 1552      без: 1172       них:  939      знаю: 782

3-грами, відсортовані за спаданням умовної ймовірності:

о том что,      0.54, 0.00030   по крайней мере, 0.99, 0.00010
в том что,      0.45, 0.00020   несмотря на то,  0.41, 0.00010
для того чтобы, 0.84, 0.00018   так и не,        0.22, 0.00009
на то что,      0.58, 0.00015   в конце концов,  0.62, 0.00009
в то время,     0.56, 0.00013   в первый раз,    0.80, 0.00009
я не знаю,      0.12, 0.00011   в это время,     0.57, 0.00009
так же как,     0.34, 0.00011   в самом деле,    0.59, 0.00009
на самом деле,  0.85, 0.00011   не может быть,   0.35, 0.00009
то время как,   0.70, 0.00010   после того как,  0.88, 0.00009
то что он,      0.11, 0.00010   до сих пор,      1.00, 0.00009

Якщо обмежитися достатньо коротким корпусом довжиною 1'097'544 слів і слова, що зустрічаються менше 10 разів, замінити на <UNK>, то вийде словник у 10'598 слів. У цьому випадку, на відміну від букв, суттєвого зменшення перплексії досягти не можна. Так для MarkovInterpolated(beta=1, minN = 3) маємо:

order:          1         2        3       4        5
perplexity:   454.94    250.91   236.91  239.85   241.06
Число унікальних n-грам: [10'598, 331'262, 757'821, 990'097, 1'068'222] достатньо велике і для якісної роботи марковської моделі потрібен суттєво більший корпус текстів.

У національному корпусі російської мови www.ruscorpora.ru довжиною близько 200 млн слів. Там же доступні для завантаження 2,3,4,5 - грами словоформ з їхніми частотами.

Для англійських слів на корпусі 3'825'715 слів (ROCStories) і словнику 10'180 (див. файл 100KStories.zip), маємо:

order:         1         2        3       4        5        6
perplexity:  479.75   156.73   117.26   112.24   112.22   112.30
Результати можна поліпшити, якщо обчислювати n-грами і перплексію за реченнями (у ROCStories вони виділені).


Загальні проблеми марковських моделей

Крім зазначених вище проблем великих словників, існує ряд концептуальних обмежень марковських моделей.


Додаток


Деревоподібне представлення n-грам

Напишемо на Python код, який обчислює спільні і умовні ймовірності, для послідовності букв або слів. При аналізі тексту будемо будувати дерево послідовностей, що зустрічаються, і запам'ятовувати число таких зустрічей. Розглянемо таке дерево на прикладі тексту "мама_мыла_раму" (рівень символів).

Нехай ми обмежилися триграмами (необхідними для обчислення спільних $P(x_1,x_2,x_3)$ і умовних $P(x_1,x_2\Rightarrow x_3)$ ймовірностей). Будемо ковзати текстом вікном у три символи, зсуваючись на один символ. Перша послідовність "мам" дає першу гілку дерева root -> м -> а -> м. У кожному вузлі записуємо одиницю. Наступна послідовність "ама" породить ще одну гілку, що виходить із кореня. Третя послідовність "ма_" починається як перша гілка, тому перші два її вузли збільшать лічильники в "м" і "а", а другий вузол "а" розщепиться на дві гілки (для символів "м", "_").

Неважко побачити, що умовна ймовірність $P(\mathbf{ма}\Rightarrow \mathbf{м})$ дорівнює відношенню числа в нижньому вузлі "м" до числа в попередньому йому вузлі "a", тобто 1/2=0.5. Спільна ймовірність $P(\mathbf{мам})$ дорівнює відношенню числа в останньому вузлі до загального числа послідовностей довжини 3. Це ж дерево дає коротші умовні або спільні ймовірності: $P(\mathbf{м} \Rightarrow \mathbf{а}) = P(\mathbf{ма})/P(\mathbf{а})=2/3$.

На Python будемо зберігати кожен вузол у формі словника всіх гілок, що виходять з нього, із зазначенням числа count потрапляння у цю гілку від кореня: words = { word: [count, words], ...}. Словник word має таку ж структуру. Наприклад, дерево вище починається таким чином:

{'м': [3, {'а': [2, {'м': [1, {}], 
                     '_': [1, {}]  }], 
           'ы': [1, {'л': [1, {}]}  ]]
          }
      ], 
  ...
}

Клас NGramsCounter

Наведемо клас NGramsCounter, який підраховує умовні і спільні ймовірності максимальної глибини order. Словами можуть бути символи, рядки або числа (індекси слів).

class NGramsCounter:        
    def __init__(self, order = 1):
        self.order = order           # глибина дерева (макс. довжина n-grams)        
        self.root  = {}              # корінь дерева (наш словник)
        self.ngrams= [0]*(order+1)   # число n-grams довжини n=1,2,...,order

Метод add додає нові приклади зі списку слів lst. Якщо словами є символи, то lst може бути рядком, інакше це обов'язково список.

    def add(self, lst):
        for n in range(self.order):              # скільки яких n-grams додалося
            self.ngrams[n+1] += max(len(lst) - n , 0)
            
        for i in range( len(lst) ):              # біжимо по тексту           
            node = self.root
            for n, w in enumerate(lst[i: i+self.order]):       
                if w in node: node[w][0] += 1    # це слово вже було                  
                else:         node[w] = [1, {}]  # нове слово
                node = node[w][1]                # на наступний вузол дерева

Метод prob отримує на вхід список слів lst і повертає умовну ймовірність P(lst[0],...,lst[len-2] => lst[len-1]), при len==1: P(lst[0]), якщо cond = True або безумовну ймовірність при cond = False Якщо слова це символи, то lst може бути рядком, інакше це список.

                
    def prob(self, lst, cond=True):
        n_cur, n_prv, _ = self.counts(lst)
        if n_cur == 0:
            return 0                              # слово не знайшли - ймовірність 0
             
        return n_cur/(n_prv if cond else self.ngrams[len(lst)])        

Повний код класу (з додатковими перевірками і іншими методами) можна знайти в my_ngrams.py.


Приклади використання NGramsCounter

                
counter = NGramsCounter(3)                      # буде 1,2,3 - грам
counter.add('мама мыла раму')                   # підраховуємо n-грами букв

print("розмір словника:", len(counter.root))    # 7
print("слова  словника:", counter.branches())   # [('м', 4), ('а', 4), ...]
print(counter.ngrams[1:])                       # [14, 13, 12] число n-грам n=1,2,3
print(counter.unique() )                        # [7, 10, 12] унікальних n-грам

N1, N2, _ = counter.counts('м')                 # N1 раз була 'м', N2 - довжина тексту 
print("N('м'), N       = ", N1, N2)             # 4, 14 
print("P('м')          = ", counter.prob('м'))  # 0.2857 = 4/14 = N1/N2

N1, N2, _ = counter.counts('ма')                # N1 раз було 'ма', N2 - було 'м'
print("N('ма'),N('м')  = ", N1, N2)             # 2, 4 
print("P('м=>а')       = ", counter.prob('ма')) # 0.5 = 2/4 = N1/N2

print("P(ма=>м) = ", counter.prob('мам'))       # 0.5   = 2/4  умовна 
print("P(мам)   = ", counter.prob('мам', False))# 0.083 = 1/12 спільна

print(counter.branches('ма'))                   # [('м', 1), (' ', 1)] що після ма

counter.all_branches()                          
# [(['м', 'а', 'м'], 1), (['м', 'а', ' '], 1),... всі гілки

Ймовірності в класі NGramsCounter обчислюються як просте відношення N1/N2. Для більш просунутих способів слід використовувати нащадків від класу Markov.

Мовні моделі містять у собі counter, який наповнюється у функціях add. Але можна їм передати і зовнішній counter, що зручно при використанні кількох моделей:
                
lm = MarkovLaplas(beta=1, order=3, counter=counter)

print(lm.counter.prob("ру"))                    # 0    
print(lm.prob("ру"))                            # 0.125 = (0+1)/(1+7) 
print(lm.perplexity("мама умыла раму"))         # 4.886
Доступні такі мовні моделі: