ML: : Передбачення букв


Вступ

Розглянемо приклад прогнозування чергової букви тексту за допомогою рекурентної нейронної мережі. Вихідний код можна знайти в нотбуці NN_RNN_Torch_Chars.ipynb


Формування даних

Як невеликий полігон будемо використовувати "Казку про царя Салтана". Алфавіт CHARS візьмемо фіксованим і включимо до нього кінець рядка '\n' (вірші):

CHARS  = " .абвгдежзийклмнопрстуфхцчшщъыьэюя\n"        # алфавіт
charID = { c:i for i,c in enumerate(CHARS) }           # буква в номер
Завантажимо текст з файлу, переведемо у нижній регістр, вичистимо подвійні пробіли тощо:
import re

with open("saltan.txt", "r", encoding='utf-8-sig') as file:
    text = file.read()
    
    text = text.lower().replace('ё','e')
    text = ''.join( [c if c in CHARS else ' ' for c in text] )
    text = re.sub(' +',    ' ',  text).replace(' .', '.')
    text = re.sub('\n\s+', '\n', text)

Для формування навчальних даних ковзаємо по тексту вікном довжиною LENGTH з кроком STEP букв. Номери букв, що потрапили у вікно, поміщаємо в X_dat. У цільовий масив Y_dat поміщаємо букви, зсунуті на одну вперед:

LENGTH, STEP = 25, 25                         # довжина історії, зміщення по тексту
textID = [ charID[c] for c in text ]          # текст - список індексів (номери в CHARS)

num_seq  = int((len(textID)-LENGTH)/STEP)-1   # число послідовностей

X_dat = torch.empty (num_seq, LENGTH, dtype=torch.long)   
Y_dat = torch.empty (num_seq, LENGTH, dtype=torch.long)

for i in range(num_seq):        
    X_dat[i] = torch.tensor(textID[i*STEP:   i*STEP+LENGTH],   dtype=torch.long)
    Y_dat[i] = torch.tensor(textID[i*STEP+1: i*STEP+LENGTH+1], dtype=torch.long)
Крок STEP вибрано рівним ширині вікна LENGTH, щоб перевірочні дані після перемішування не перетнулися з тренувальними (див. наступний розділ). Перемішаємо дані і розіб’ємо їх на тренувальне (X_trn, Y_trn) і перевірочне (X_val, Y_val) множини:
idx = torch.randperm( len(X_dat) )            # перемішаний список індексів
X_dat, Y_dat = X_dat[idx], Y_dat[idx]

num_trn = int(0.75*len(X_dat))                # частка тренувальних даних
X_trn, Y_trn = X_dat[:num_trn], Y_dat[:num_trn]
X_val, Y_val = X_dat[num_trn:], Y_dat[num_trn:]

Стратегія навчання

У принципі, можна навчати мережу передбачати Y_trn за входом X_trn, задіюючи всі виходи рекурентного шару. Однак ми зробимо інакше. Введемо гіперпараметр NUM, який менший за довжину послідовності LENGTH. За першими LENGTH - NUM комірками мережа буде накопичувати прихований стан (історію). Помилки ж передбачення обчислюватимуться лише за останніми NUM комірками. Використання значень NUM > 1 доцільне для зменшення затухання градієнта при його зворотному поширенні.

Нижче намальована двошарова RNN-мережа. На входи комірок першого шару надходить текст "вечерко" Насправді одночасно в навчанні бере участь не один, а B прикладів довжини L (батч). Тому на вході мережі знаходиться тензор форми (B,L), що містить номери букв. Він проходить через шар Embedding і кожна буква перетворюється на E-вимірний вектор, тобто виходить тензор (B,L,E):

Вихід останнього RNN-шару має розмірність (B,L,H), де H - розмірність прихованого стану. Для обчислення помилки беруться лише останні NUM комірки. Тому тензор (B,NUM,H) за допомогою повнозв’язного шару fc (fully connected) з матрицею форми (H,C) перетворюється на тензор (B,NUM,С), де С-число букв в алфавіті (число класів). Після пропуску цього тензора через функцію софтмакса sm для кожного прикладу B і виходу NUM виходить С чисел, які інтерпретуються як ймовірності тієї чи іншої букви (сума чисел після sm дорівнює одиниці). Помилка передбачення обчислюється як середня CrossEntropyLoss по всіх виходах NUM і прикладах B.


Модель

У конструкторі моделі створимо шар ембедингу, стопку з NUM_LAYERS рекурентних шарів GRU, вихідний повнозв’язний шар Linear і Dropout шар, який з імовірністю DROP обнуляє компоненти тензора перед fc:
class Model(nn.Module):
    def __init__(self, C, E, H, LAYERS = 1, DROP = 0):           
        super(Model, self).__init__()              
        
        self.emb  = nn.Embedding(C, E, scale_grad_by_freq=True)        
        self.rnn  = nn.GRU    (E, H, num_layers=LAYERS, batch_first=True, dropout=0.2)
        self.drop = nn.Dropout(DROP)           
        self.fc   = nn.Linear (H*LAYERS, C) 

Звернімо увагу на параметр scale_grad_by_freq при створенні шару Embedding. Букви в тексті мають суттєво різну ймовірність. Тому під час навчання більш часті букви інтенсивніше змінюватимуть компоненти своїх векторів, ніж більш рідкісні. При параметрі scale_grad_by_freq = True за поточним батчем обчислюються ймовірності букв і вектори більш рідкісних букв оптимізатор зсуває сильніше.

Метод прямого поширення forward визначає послідовність обчислень. Праворуч у коментарях наведено форми отримуваних тензорів.
                                               
.   def forward(self, x, h0=None, NUM=1):       # (B,L), (1,B,H) 
        x      = self.emb ( x )                 # (B,L,E)        
        yr, hr = self.rnn ( x, h0 )             # (B,L,H),  (1,B,H)  
        
        y = yr[:, -NUM:,  :]                    # (B,NUM,H) останні виходи  
        y = self.drop(y)
        y = self.fc(y)                          # (B,NUM,C)
        return y.transpose(1,2), hr             # (B,C,NUM), (LAYERS,B,H)
У нашій стратегії навчання матриця повнозв’язного шару fc згортається не з усім вихідним тензором (B,L,H), а лише з його зрізом (B,NUM,H). Крім прискорення, це може призводити до суттєвої економії пам’яті при великому розмірі словника (коли замість букв працюють зі словами). Нехай, наприклад, у батчі B=100 прикладів, розмірність прихованого стану H=500 і в словнику C=100'000 слів. Тоді тензор усіх виходів RNN-шару має 100*500*L елементів, а тензор після повнозв’язного шару в 200 разів його більший: 100*100000*L. При довгих послідовностях L такий тензор може навіть не поміститися в пам’ять графічної карти. Тому згортати всі виходи RNN шару з повнозв’язним вихідним шаром fc не доцільно.

Створення екземпляра моделі стандартне. За наявності графічної карти gpu параметри моделі одразу відправляються в її пам’ять. Список losses нам знадобиться далі для візуалізації історії помилок навчання.

E_DIM, H_DIM, NUM_LAYERS, DROP = 10, 250, 1, 0

model = Model(len(CHARS), E_DIM, H_DIM)         # екземпляр мережі

gpu = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
cpu = torch.device("cpu")

model.to(gpu)
losses = []                                     # історія помилок для графіка

Ваги класів і функція помилки

Ще один спосіб боротьби з перекосом ймовірностей класів (букв) — це використання ваг ступеня важливості помилки даної букви. Якщо всі ваги однакові, то мережа прагне краще передбачати часті букви, ніж більш рідкісні. Щоб знизити цей ефект, обчислимо частоту кожної букви weight, а потім візьмемо її логарифм з оберненим знаком.

weight = torch.zeros(len(CHARS), dtype=torch.float)
for c in textID:
    weight[c] += 1
    
weight /= len(text)
weight = -weight.log_()
Наведемо ваги для 5 найчастіших і 5 найрідкісніших букв:
     ' ': 1.86   'о': 2.51   'е': 2.76   'а': 2.72   'и': 2.98
     'ю': 5.49   'щ': 6.44   'э': 7.49   'ъ': 7.49   'ф': 8.95
Використання логарифма робить збільшення ваги рідкісних букв більш м’яким порівняно, наприклад, з оберненою ймовірністю. Нормувати ваги не обов’язково, бо функція помилки це робить сама.
CE_loss   = nn.CrossEntropyLoss(weight.to(gpu))

Навчання

Функція навчання на одну епоху має вигляд:
def fit(model, X,Y, batch_size=64, NUM=1, train=True):        
    model.train(train)                                   # важливо для Dropout
    sumL, iters = 0, int(len(X)/batch_size) 
        
    tm1 = tm()
    for it in range(iters):                                # приклади розбиті на пачки 
        
        xb = X[it*batch_size: (it+1)*batch_size].to(gpu)   # (B,L)
        yb = Y[it*batch_size: (it+1)*batch_size].to(gpu)   # (B,L) 
                
        y, _ = model(xb, NUM=NUM)           
        L    = CE_loss(y, yb[:, -NUM:]) 
        
        sumL += L.detach().item()
        
        if train:                                        # у режимі навчання     
            optimizer.zero_grad()                        # обнуляємо градієнти        
            L.backward()                                 # обчислюємо градієнти            
            optimizer.step()                             # підправляємо параметри
                
        if tm() - tm1 > 1 or it+1==iters:
            print('\r', f"{100*(it+1)/iters:.0f}%  loss: {sumL/(it+1):.4f}", end='')
            tm1 = tm()
    
    return sumL/iters

Запуск навчання:

from IPython.display import clear_output
import matplotlib.pyplot as plt

for epoch in range(100):                   
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999))
    
    idx = torch.randperm( len(X_trn) )     # перемішаний список індексів
    X_trn = X_trn[idx]
    Y_trn = Y_trn[idx]
    
    L_trn, A_trn = fit(model, X_trn, Y_trn, 256, NUM = 25, train=True )      
    L_val, A_val = fit(model, X_val, Y_val, 256, NUM = 25, train=False) 
    losses.append([L_trn, L_val])
               
    clear_output(wait=True)
    plt.figure(figsize=(16,4)); plt.plot(losses); plt.legend(['trn', 'val']);  plt.show()
                           
    print('\r', f'epoch: {epoch:d}/{len(losses)-1}  {tm()-beg:.2f}s ', 
                f'loss: trn={L_trn:.4f} val={L_val:.4f}  ' )  

One-hot vs Embedding

Оскільки букв у словнику небагато, можна було б прибрати шар Embedding, виконавши one-hot кодування. Для цього достатньо покласти E_DIM=len(CHARS) і замінити у функції forward рядок x = self.emb ( x ) на:

B, L, C = len(x), x.size(1), self.fc.size(-1)
x = torch.zeros(B,L,C).scatter_(2, x.unsqueeze(2), 1.)    # (B,L,C)
x = x.transpose(0,1).contiguous()                         # (L,B,C)
Оскільки x має форму (B,L), спочатку створюється тензор (B,L,C), заповнений нулями. Метод x.unsqueeze(2) — це зміна форми тензора: (B,L) -> (B,L,1). Метод scatter_ ставить в останньому індексі тензора одиниці в позиції, що відповідає значенню x. Псевдокод для цього методу: self[i,j, x[i,j,k]] = 1. Наприклад:
L, C = 3, 4
x = torch.tensor([ [1,0,1],  [0,2,3] ], dtype=torch.long)
x = torch.zeros(len(x), 3, 4).scatter_(2, x.unsqueeze(2),  1.)   # (B,L,C)

[[[0., 1., 0., 0.],  [1., 0., 0., 0.],  [0., 1., 0., 0.]],
 [[1., 0., 0., 0.],  [0., 0., 1., 0.],  [0., 0., 0., 1.]]])