9.7. Chuỗi sang Chuỗi

Mô hình chuỗi sang chuỗi (Sequence to Sequence – seq2seq) dựa trên kiến trúc mã hóa - giải mã để sinh ra chuỗi đầu ra từ chuỗi đầu vào như minh họa trong Fig. 9.7.1. Cả bộ mã hóa và bộ giải mã sử dụng mạng nơ-ron hồi tiếp (RNN) để xử lý các chuỗi đầu vào với độ dài khác nhau. Trạng thái ẩn của bộ giải mã được khởi tạo trực tiếp từ trạng thái ẩn của bộ mã hóa, giúp truyền thông tin từ bộ mã hóa tới bộ giải mã.

../_images/seq2seq.svg

Fig. 9.7.1 Kiến trúc mô hình chuỗi sang chuỗi.

Các tầng trong bộ mã hóa và bộ giải mã được minh họa trong Fig. 9.7.2.

../_images/seq2seq-details.svg

Fig. 9.7.2 Các tầng trong bộ mã hóa và bộ giải mã.

Trong phần này chúng ta sẽ tìm hiểu và lập trình mô hình seq2seq để huấn luyện trên bộ dữ liệu dịch máy.

from d2l import mxnet as d2l
from mxnet import np, npx, init, gluon, autograd
from mxnet.gluon import nn, rnn
npx.set_np()

9.7.1. Bộ Mã hóa

Nhắc lại rằng bộ mã hóa của mô hình seq2seq mã hóa thông tin của các chuỗi đầu vào với độ dài khác nhau thành một vector ngữ cảnh \(\mathbf{c}\). Ta thường sử dụng các tầng RNN trong bộ mã hóa. Giả sử có một chuỗi đầu vào \(x_1, \ldots, x_T\), trong đó \(x_t\) là từ thứ \(\mathrm{t}\). Tại bước thời gian \(t\), mô hình RNN sẽ có hai vector đầu vào: vector đặc trưng \(\mathbf{x}_t\) của \(x_t\) và trạng thái ẩn của bước thời gian trước đó \(\mathbf{h}_{t-1}\). Ta ký hiệu phép chuyển đổi của các trạng thái ẩn trong RNN bằng hàm \(f\):

(9.7.1)\[\mathbf{h}_t = f (\mathbf{x}_t, \mathbf{h}_{t-1}).\]

Tiếp theo, bộ mã hóa nắm bắt thông tin của tất cả các trạng thái ẩn và mã hóa chúng thành vector ngữ cảnh \(\mathbf{c}\) bằng hàm \(q\):

(9.7.2)\[\mathbf{c} = q (\mathbf{h}_1, \ldots, \mathbf{h}_T).\]

Ví dụ, nếu chúng ta chọn \(q\)\(q (\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_T\), thì vector ngữ cảnh sẽ là trạng thái ẩn của bước thời gian cuối cùng \(\mathbf{h}_T\).

Cho đến nay ta mới mô tả bộ mã hóa sử dụng mạng RNN một chiều, ở đó trạng thái ẩn của mỗi bước thời gian chỉ phụ thuộc vào các bước thời gian trước. Ta cũng có thể sử dụng các dạng RNN khác nhau như GRU, LSTM, hay RNN hai chiều để mã hóa chuỗi đầu vào.

Bây giờ hãy lập trình bộ mã hóa của mô hình seq2seq. Ta sử dụng một tầng embedding từ ngữ để lấy vector đặc trưng tương ứng với chỉ số từ trong ngôn ngữ nguồn. Những vector đặc trưng này sẽ được truyền vào một mạng LSTM đa tầng. Batch đầu vào của bộ mã hóa là tensor 2 chiều có kích thước là (kích thước batch, độ dài chuỗi), với số lượng chuỗi bằng kích thước batch. Bộ mã hóa trả về cả đầu ra của LSTM, gồm các trạng thái ẩn của tất cả các bước thời gian, cùng với trạng thái ẩn và ô nhớ ở bước thời gian cuối cùng.

# Saved in the d2l package for later use
class Seq2SeqEncoder(d2l.Encoder):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqEncoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = rnn.LSTM(num_hiddens, num_layers, dropout=dropout)

    def forward(self, X, *args):
        X = self.embedding(X)  # X shape: (batch_size, seq_len, embed_size)
        # RNN needs first axes to be timestep, i.e., seq_len
        X = X.swapaxes(0, 1)
        state = self.rnn.begin_state(batch_size=X.shape[1], ctx=X.ctx)
        out, state = self.rnn(X, state)
        # out shape: (seq_len, batch_size, num_hiddens)
        # state shape: (num_layers, batch_size, num_hiddens),
        # where "state" contains the hidden state and the memory cell
        return out, state

Tiếp theo, chúng ta sẽ tạo một minibatch đầu vào dạng chuỗi với kích thước batch bằng 4 cùng số bước thời gian (độ dài chuỗi) bằng 7. Giả sử nút LSTM có 2 tầng ẩn và 16 nút ẩn. Đầu ra của bộ mã hóa sau khi thực hiện lượt truyền xuôi trên đầu vào có kích thước là (số bước thời gian, kích thước batch, số nút ẩn). Nếu mạng nơ-ron hồi tiếp của bộ mã hóa là nút hồi tiếp có cổng (GRU), danh sách state chỉ chứa một phần tử, đó là trạng thái ẩn với kích thước (số tầng ẩn, kích thước batch, số nút ẩn). Nếu LSTM được sử dụng thì danh sách state sẽ chứa thêm một phần tử khác, đó là ô nhớ với cùng kích thước.

encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
encoder.initialize()
X = np.zeros((4, 7))
output, state = encoder(X)
output.shape
(7, 4, 16)

Trong trường hợp này, vì LSTM đang được sử dụng, danh sách state sẽ chứa cả trạng thái ẩn và ô nhớ với cùng kích thước (số tầng ẩn, kích thước batch, số nút ẩn).

len(state), state[0].shape, state[1].shape
(2, (2, 4, 16), (2, 4, 16))

9.7.2. Bộ giải mã

Như đã giới thiệu, vector ngữ cảnh \(\mathbf{c}\) mã hóa thông tin của toàn bộ chuỗi đầu vào \(x_1, \ldots, x_T\). Giả sử đầu ra của tập huấn luyện là \(y_1, \ldots, y_{T'}\). Tại mỗi bước thời gian \(t'\), xác suất có điều kiện của đầu ra \(y_{t'}\) sẽ phụ thuộc vào đầu ra trước đó \(y_1, \ldots, y_{t'-1}\) và vector ngữ cảnh \(\mathbf{c}\), tức

(9.7.3)\[P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \mathbf{c}).\]

Do đó, chúng ta có thể sử dụng một mạng RNN khác trong bộ giải mã. Tại mỗi bước thời gian \(t'\), bộ giải mã cập nhật trạng thái ẩn của nó thông qua ba đầu vào: vector đặc trưng \(\mathbf{y}_{t'-1}\) của \(y_{t'-1}\), vector ngữ cảnh \(\mathbf{c}\) và trạng thái ẩn tại bước thời gian trước đó \(\mathbf{s}_{t'-1}\). Hàm \(g\) dưới đây biểu diễn quá trình biến đổi trạng thái ẩn của mạng RNN trong bộ giải mã:

(9.7.4)\[\mathbf{s}_{t'} = g(\mathbf{y}_{t'-1}, \mathbf{c}, \mathbf{s}_{t'-1}).\]

Khi lập trình, ta sử dụng trực tiếp trạng thái ẩn của bộ mã hóa ở bước thời gian cuối cùng để khởi tạo trạng thái ẩn của bộ giải mã. Điều này đòi hỏi bộ mã hóa và bộ giải mã phải có cùng số tầng và số nút ẩn. Các bước tính toán lượt truyền xuôi trong bộ giải mã gần giống trong bộ mã hóa. Điểm khác biệt duy nhất là có thêm một tầng kết nối dày đặc với kích thước bằng kích thước bộ từ vựng được đặt ở sau các tầng LSTM. Tầng này sẽ dự đoán điểm tin cậy cho mỗi từ.

# Saved in the d2l package for later use
class Seq2SeqDecoder(d2l.Decoder):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqDecoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = rnn.LSTM(num_hiddens, num_layers, dropout=dropout)
        self.dense = nn.Dense(vocab_size, flatten=False)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]

    def forward(self, X, state):
        X = self.embedding(X).swapaxes(0, 1)
        out, state = self.rnn(X, state)
        # Make the batch to be the first dimension to simplify loss
        # computation
        out = self.dense(out).swapaxes(0, 1)
        return out, state

Ta tạo bộ giải mã với cùng các siêu tham số như ở bộ mã hóa. Có thể thấy kích thước đầu ra được thay đổi thành (kích thước batch, độ dài chuỗi, kích thước bộ từ vựng).

decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8,
                         num_hiddens=16, num_layers=2)
decoder.initialize()
state = decoder.init_state(encoder(X))
out, state = decoder(X, state)
out.shape, len(state), state[0].shape, state[1].shape
((4, 7, 10), 2, (2, 4, 16), (2, 4, 16))

9.7.3. Hàm Mất mát

Tại mỗi bước thời gian, bộ giải mã tạo ra một vector điểm tin cậy có kích thước bằng bộ từ vựng để dự đoán các từ. Tương tự như trong mô hình hóa ngôn ngữ, ta có thể áp dụng softmax để tính xác suất và sau đó sử dụng hàm mất mát entropy chéo để tính mất mát. Lưu ý rằng ta đã đệm các câu đích để chúng có cùng độ dài, nhưng không cần tính mất mát trên các ký tự đệm này.

Để lập trình hàm mất mát có khả năng lọc ra một số phần tử, ta sẽ sử dụng một toán tử gọi là SequenceMask. Nó có thể gán mặt nạ cho chiều thứ nhất (axis=0) hoặc thứ hai (axis=1). Nếu chiều thứ hai được chọn, với đầu vào là mảng hai chiều X và vector độ dài hợp lệ len, toán tử này sẽ gán X[i, len[i]:] = 0 với mọi \(i\).

X = np.array([[1, 2, 3], [4, 5, 6]])
npx.sequence_mask(X, np.array([1, 2]), True, axis=1)
array([[1., 0., 0.],
       [4., 5., 0.]])

Áp dụng vào tensor \(n\)-chiều \(X\), toán tử sẽ gán X[i, len[i]:, :, ..., :] = 0. Ta cũng có thể đặt giá trị mặt nạ khác, ví dụ như \(-1\) dưới đây.

X = np.ones((2, 3, 4))
npx.sequence_mask(X, np.array([1, 2]), True, value=-1, axis=1)
array([[[ 1.,  1.,  1.,  1.],
        [-1., -1., -1., -1.],
        [-1., -1., -1., -1.]],

       [[ 1.,  1.,  1.,  1.],
        [ 1.,  1.,  1.,  1.],
        [-1., -1., -1., -1.]]])

Bây giờ ta có thể lập trình phiên bản có mặt nạ của hàm mất mát entropy chéo softmax. Lưu ý rằng hàm mất mát trong Gluon cho phép đặt trọng số cho mỗi mẫu, theo mặc định thì giá trị này bằng 1. Để loại bỏ một vài mẫu nhất định, ta có thể đặt trọng số cho chúng bằng 0. Vì vậy, hàm mất mát có mặt nạ sẽ có thêm đối số valid_len cho toán tử SequenceMask để gán giá trị 0 cho trọng số của các mẫu ta muốn loại bỏ.

# Saved in the d2l package for later use
class MaskedSoftmaxCELoss(gluon.loss.SoftmaxCELoss):
    # pred shape: (batch_size, seq_len, vocab_size)
    # label shape: (batch_size, seq_len)
    # valid_len shape: (batch_size, )
    def forward(self, pred, label, valid_len):
        # weights shape: (batch_size, seq_len, 1)
        weights = np.expand_dims(np.ones_like(label), axis=-1)
        weights = npx.sequence_mask(weights, valid_len, True, axis=1)
        return super(MaskedSoftmaxCELoss, self).forward(pred, label, weights)

Để kiểm tra sơ bộ, ta tạo ba chuỗi giống hệt nhau, giữ 4 phần tử cho chuỗi thứ nhất, 2 phần tử cho chuỗi thứ hai và không phần tử nào cho chuỗi cuối cùng. Khi đó, giá trị mất mát của chuỗi đầu tiên phải lớn gấp 2 lần so với chuỗi thứ hai, còn giá trị mất mát của chuỗi cuối cùng phải bằng 0.

loss = MaskedSoftmaxCELoss()
loss(np.ones((3, 4, 10)), np.ones((3, 4)), np.array([4, 2, 0]))
array([2.3025851, 1.1512926, 0.       ])

9.7.4. Huấn luyện

Trong quá trình huấn luyện, nếu chuỗi đích có độ dài \(n\), ta sẽ đưa \(n-1\) token đầu tiên làm đầu vào bộ giải mã, còn \(n-1\) token cuối cùng sẽ được sử dụng làm nhãn gốc.

# Saved in the d2l package for later use
def train_s2s_ch9(model, data_iter, lr, num_epochs, ctx):
    model.initialize(init.Xavier(), force_reinit=True, ctx=ctx)
    trainer = gluon.Trainer(model.collect_params(),
                            'adam', {'learning_rate': lr})
    loss = MaskedSoftmaxCELoss()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[1, num_epochs], ylim=[0, 0.25])
    for epoch in range(1, num_epochs + 1):
        timer = d2l.Timer()
        metric = d2l.Accumulator(2)  # loss_sum, num_tokens
        for batch in data_iter:
            X, X_vlen, Y, Y_vlen = [x.as_in_ctx(ctx) for x in batch]
            Y_input, Y_label, Y_vlen = Y[:, :-1], Y[:, 1:], Y_vlen-1
            with autograd.record():
                Y_hat, _ = model(X, Y_input, X_vlen, Y_vlen)
                l = loss(Y_hat, Y_label, Y_vlen)
            l.backward()
            d2l.grad_clipping(model, 1)
            num_tokens = Y_vlen.sum()
            trainer.step(num_tokens)
            metric.add(l.sum(), num_tokens)
        if epoch % 10 == 0:
            animator.add(epoch, (metric[0]/metric[1],))
    print('loss %.3f, %d tokens/sec on %s ' % (
        metric[0]/metric[1], metric[1]/timer.stop(), ctx))

Tiếp theo, ta tạo một thực thể của mô hình, đặt các siêu tham số rồi huấn luyện.

embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.0
batch_size, num_steps = 64, 10
lr, num_epochs, ctx = 0.005, 300, d2l.try_gpu()

src_vocab, tgt_vocab, train_iter = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(
    len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqDecoder(
    len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
model = d2l.EncoderDecoder(encoder, decoder)
train_s2s_ch9(model, train_iter, lr, num_epochs, ctx)
loss 0.025, 8717 tokens/sec on gpu(0)
../_images/output_seq2seq_vn_14db6e_23_1.svg

9.7.5. Dự đoán

Ở đây, ta lập trình phương pháp đơn giản nhất có tên gọi tìm kiếm tham lam (greedy search), để tạo chuỗi đầu ra. Như minh họa trong Fig. 9.7.3, trong quá trình dự đoán, ta cũng đưa token bắt đầu câu “<bos>” vào bộ giải mã tại bước thời gian 0 giống quá trình huấn luyện. Token đầu vào cho các bước thời gian sau sẽ là token được dự đoán từ bước thời gian trước nó.

../_images/seq2seq_predict.svg

Fig. 9.7.3 Quá trình dự đoán của mô hình chuỗi sang chuỗi sử dụng tìm kiếm tham lam

# Saved in the d2l package for later use
def predict_s2s_ch9(model, src_sentence, src_vocab, tgt_vocab, num_steps,
                    ctx):
    src_tokens = src_vocab[src_sentence.lower().split(' ')]
    enc_valid_len = np.array([len(src_tokens)], ctx=ctx)
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
    enc_X = np.array(src_tokens, ctx=ctx)
    # Add the batch_size dimension
    enc_outputs = model.encoder(np.expand_dims(enc_X, axis=0),
                                enc_valid_len)
    dec_state = model.decoder.init_state(enc_outputs, enc_valid_len)
    dec_X = np.expand_dims(np.array([tgt_vocab['<bos>']], ctx=ctx), axis=0)
    predict_tokens = []
    for _ in range(num_steps):
        Y, dec_state = model.decoder(dec_X, dec_state)
        # The token with highest score is used as the next timestep input
        dec_X = Y.argmax(axis=2)
        py = dec_X.squeeze(axis=0).astype('int32').item()
        if py == tgt_vocab['<eos>']:
            break
        predict_tokens.append(py)
    return ' '.join(tgt_vocab.to_tokens(predict_tokens))

Ta sẽ thử một vài ví dụ:

for sentence in ['Go .', 'Wow !', "I'm OK .", 'I won !']:
    print(sentence + ' => ' + predict_s2s_ch9(
        model, sentence, src_vocab, tgt_vocab, num_steps, ctx))
Go . => va !
Wow ! => <unk> !
I'm OK . => je vais bien .
I won ! => j'ai gagné !

9.7.6. Tóm tắt

  • Mô hình chuỗi sang chuỗi (sequence to sequence - seq2seq) dựa trên kiến trúc mã hóa-giải mã để tạo một chuỗi đầu ra từ chuỗi đầu vào.
  • Ta sử dụng nhiều tầng LSTM cho cả bộ mã hóa và bộ giải mã.

9.7.7. Bài tập

  1. Nêu một vài ứng dụng khác của seq2seq ngoài dịch máy.
  2. Nếu chuỗi đầu vào trong các ví dụ trên dài hơn thì sao?
  3. Điều gì có thể xảy ra nếu không sử dụng SequenceMask trong hàm mất mát?

9.7.8. Thảo luận

9.7.9. Những người thực hiện

Bản dịch trong trang này được thực hiện bởi:

  • Đoàn Võ Duy Thanh
  • Nguyễn Văn Quang
  • Đỗ Trường Giang
  • Phạm Minh Đức
  • Nguyễn Duy Du
  • Phạm Hồng Vinh
  • Lê Khắc Hồng Phúc
  • Nguyễn Văn Cường