11.9. Hạ Gradient Ngẫu nhiên theo Minibatch

Đến giờ, ta đã tiếp xúc với hai thái cực trong các phương pháp học dựa theo gradient: tại mỗi lượt Section 11.7 sử dụng toàn bộ tập dữ liệu để tính gradient và cập nhật tham số. Ngược lại, Section 11.8 xử lý từng điểm dữ liệu một để cập nhật các tham số. Mỗi phương pháp đều có mặt hạn chế riêng. Hạ Gradient có hiệu suất dữ liệu (data efficiency) thấp khi dữ liệu tương đối giống nhau. Hạ Gradient Ngẫu nhiên có hiệu suất tính toán (computational efficiency) thấp do CPU và GPU không được khai thác hết khả năng vector hóa. Điều này gợi ý rằng có thể có một phương pháp thích hợp ở giữa, và thực tế, ta đã sử dụng phương pháp đó trong các ví dụ đã thảo luận.

11.9.1. Vector hóa và Vùng nhớ đệm

Lý do sử dụng minibatch chủ yếu là vì hiệu suất tính toán. Để dễ hiểu, ta xét trường hợp tính toán song song giữa nhiều GPU và giữa nhiều máy chủ. Trong trường hợp này ta cần đưa ít nhất một ảnh vào mỗi GPU. Với 16 máy chủ và 8 GPU mỗi máy, ta có minibatch kích thước 128.

Vấn đề trở nên nhạy cảm hơn đối với GPU đơn hay ngay cả CPU đơn. Những thiết bị này có nhiều loại bộ nhớ, thường có nhiều loại đơn vị thực hiện tính toán và giới hạn băng thông giữa các đơn vị này cũng khác nhau. Ví dụ, một CPU có số lượng ít thanh ghi, bộ nhớ đệm L1, L2 và trong một số trường hợp có cả L3 (phần bộ nhớ được phân phối giữa các lõi của vi xử lý). Các bộ nhớ đệm đang tăng dần về kích thước và độ trễ (và cùng với đó là giảm băng thông). Nói vậy đủ thấy rằng vi xử lý có khả năng thực hiện nhiều tác vụ hơn so với những gì mà giao diện bộ nhớ chính (main memory interface) có thể cung cấp.

  • Một CPU tốc độ 2GHz với 16 lõi và phép vector hóa AVX-512 có thể xử lý lên lới \(2 \cdot 10^9 \cdot 16 \cdot 32 = 10^{12}\) byte mỗi giây. Khả năng của GPU dễ dàng vượt qua con số này cả trăm lần. Mặt khác, trong vi xử lý của máy chủ cỡ trung bình, băng thông có lẽ không vượt quá 100 GB/s, tức là chưa bằng một phần mười băng thông yêu cầu để đưa dữ liệu vào bộ xử lý. Vấn đề còn tồi tệ hơn khi ta xét đến việc không phải khả năng truy cập bộ nhớ nào cũng như nhau: đầu tiên, giao diện bộ nhớ thường rộng 64 bit hoặc hơn (ví dụ như trên GPU lên đến 384 bit), do đó việc đọc một byte duy nhất vẫn sẽ phải chịu chi phí giống như truy cập một khoảng bộ nhớ rộng hơn.
  • Tổng chi phí cho lần truy cập đầu tiên là khá lớn, trong khi truy cập liên tiếp thường hao tổn ít (thường được gọi là đọc hàng loạt). Có rất nhiều điều cần lưu ý, ví dụ như lưu trữ đệm khi ta có nhiều điểm truy cập cuối (sockets), nhiều chiplet và các cấu trúc khác. Việc thảo luận chi tiết vấn đề trên nằm ngoài phạm vi của phần này. Bạn có thể tham khảo bài viết Wikipedia này để hiểu sâu hơn.

Cách để giảm bớt những ràng buộc trên là sử dụng hệ thống cấp bậc (hierarchy) của các vùng nhớ đệm trong CPU, các vùng nhớ này đủ nhanh để có thể cung cấp dữ liệu cho vi xử lý. Đây chính là động lực đằng sau việc sử dụng batch trong học sâu. Để đơn giản, xét phép nhân hai ma trận \(\mathbf{A} = \mathbf{B}\mathbf{C}\). Để tính \(\mathbf{A}\) ta có khá nhiều lựa chọn, ví dụ như:

  1. Ta có thể tính \(\mathbf{A}_{ij} = \mathbf{B}_{i,:} \mathbf{C}_{:,j}^\top\), tức là tính từng phần tử bằng tích vô hướng.
  2. Ta có thể tính \(\mathbf{A}_{:,j} = \mathbf{B} \mathbf{C}_{:,j}^\top\), tức là tính theo từng cột. Tương tự, ta có thể tính \(\mathbf{A}\) theo từng hàng \(\mathbf{A}_{i,:}\).
  3. Ta đơn giản có thể tính \(\mathbf{A} = \mathbf{B} \mathbf{C}\).
  4. Ta có thể chia \(\mathbf{B}\)\(\mathbf{C}\) thành nhiều khối ma trận nhỏ hơn và tính \(\mathbf{A}\) theo từng khối một.

Nếu sử dụng cách đầu tiên, ta cần sao chép một vector cột và một vector hàng vào CPU cho mỗi lần tính phần tử \(\mathbf{A}_{ij}\). Tệ hơn nữa, do các phần tử của ma trận được lưu thành một dãy liên tục dưới bộ nhớ, ta buộc phải truy cập nhiều vùng nhớ rời rạc khi đọc một trong hai vector từ bộ nhớ. Cách thứ hai tốt hơn nhiều. Theo cách này, ta có thể giữ vector cột \(\mathbf{C}_{:,j}\) trong vùng nhớ đệm của CPU trong khi ta tiếp tục quét qua \(B\). Cách này chỉ cần nửa băng thông cần thiết của bộ nhớ, do đó truy cập nhanh hơn. Đương nhiên cách thứ ba là tốt nhất. Đáng tiếc rằng đa số ma trận quá lớn để có thể đưa vào vùng nhớ đệm (dù sao đây cũng chính là điều ta đang thảo luận). Cách thứ tư là một phương pháp thay thế khá tốt: đưa các khối của ma trận vào vùng nhớ đệm và thực hiện phép nhân cục bộ. Các thư viện đã được tối ưu sẽ thực hiện việc này giúp chúng ta. Hãy xem xét hiệu suất của từng phương pháp trong thực tế.

Ngoài hiệu suất tính toán, chi phí tính toán phát sinh đến từ Python và framework học sâu cũng đáng cân nhắc. Mỗi lần ta thực hiện một câu lệnh, bộ thông dịch Python gửi một câu lệnh đến MXNet để chèn câu lệnh đó vào đồ thị tính toán và thực thi nó theo đúng lịnh trình. Chi phí đó có thể khá bất lợi. Nói ngắn gọn, nên áp dụng vector hóa (và ma trận) bất cứ khi nào có thể.

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

timer = d2l.Timer()
A = np.zeros((256, 256))
B = np.random.normal(0, 1, (256, 256))
C = np.random.normal(0, 1, (256, 256))

Phép nhân theo từng phần tử chỉ đơn giản là duyệt qua tất cả các hàng và cột của \(\mathbf{B}\)\(\mathbf{C}\) theo thứ tự rồi gán kết quả cho \(\mathbf{A}\).

# Compute A = BC one element at a time
timer.start()
for i in range(256):
    for j in range(256):
        A[i, j] = np.dot(B[i, :], C[:, j])
A.wait_to_read()
timer.stop()
45.49748992919922

Một cách nhanh hơn là nhân theo từng cột.

# Compute A = BC one column at a time
timer.start()
for j in range(256):
    A[:, j] = np.dot(B, C[:, j])
A.wait_to_read()
timer.stop()
0.13073205947875977

Cuối cùng, cách hiệu quả nhất là thực hiện toàn bộ phép nhân trong một khối. Hãy thử xem tốc độ tương ứng của phương pháp này là bao nhiêu.

# Compute A = BC in one go
timer.start()
A = np.dot(B, C)
A.wait_to_read()
timer.stop()

# Multiply and add count as separate operations (fused in practice)
gigaflops = [2/i for i in timer.times]
print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, '
      f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}')
performance in Gigaflops: element 0.044, column 15.298, full 2196.546

11.9.2. Minibatch

Ở các phần trước ta mặc nhiên đọc dữ liệu theo minibatch thay vì từng điểm dữ liệu đơn lẻ để cập nhật các tham số. Ta có thể giải thích ngắn gọn mục đích như sau. Xử lý từng điểm dữ liệu đơn lẻ đòi hỏi phải thực hiện rất nhiều phép nhân ma trận với vector (hay thậm chí vector với vector). Cách này khá tốn kém và đồng thời phải chịu thêm chi phí khá lớn đến từ các framework học sâu bên dưới. Vấn đề này xảy ra ở cả lúc đánh giá một mạng với dữ liệu mới (thường gọi là suy luận - inference) và khi tính toán gradient để cập nhật các tham số. Tức là vấn đề xảy ra mỗi khi ta thực hiện \(\mathbf{w} \leftarrow \mathbf{w} - \eta_t \mathbf{g}_t\) trong đó

(11.9.1)\[\mathbf{g}_t = \partial_{\mathbf{w}} f(\mathbf{x}_{t}, \mathbf{w})\]

Ta có thể tăng hiệu suất tính toán của phép tính này bằng cách áp dụng nó trên mỗi minibatch dữ liệu. Tức là ta thay thế gradient \(\mathbf{g}_t\) trên một điểm dữ liệu đơn lẻ bằng gradient trên một batch nhỏ.

(11.9.2)\[\mathbf{g}_t = \partial_{\mathbf{w}} \frac{1}{|\mathcal{B}_t|} \sum_{i \in \mathcal{B}_t} f(\mathbf{x}_{i}, \mathbf{w})\]

Hãy thử xem phương pháp trên tác động thế nào đến các tính chất thống kê của \(\mathbf{g}_t\): do cả \(\mathbf{x}_t\) và tất cả các phần tử trong minibatch \(\mathcal{B}_t\) được lấy ra từ tập huấn luyện với xác suất như nhau, kỳ vọng của gradient là không đổi. Mặt khác, phương sai giảm một cách đáng kể. Do gradient của minibatch là trung bình của \(b := |\mathcal{B}_t|\) gradient độc lập, độ lệch chuẩn của nó giảm đi theo hệ số \(b^{-\frac{1}{2}}\). Đây là một điều tốt, cách cập nhật này có độ tin cậy gần bằng việc lấy gradient trên toàn bộ tập dữ liệu.

Từ ý trên, ta sẽ nhanh chóng cho rằng chọn minibatch \(\mathcal{B}_t\) lớn luôn là tốt nhất. Tiếc rằng đến một mức độ nào đó, độ lệch chuẩn sẽ giảm không đáng kể so với chi phí tính toán tăng tuyến tính. Do đó trong thực tế, ta sẽ chọn kích thước minibatch đủ lớn để hiệu suất tính toán cao trong khi vẫn đủ để đưa vào bộ nhớ của GPU. Để minh hoạ quá trình lưu trữ này, hãy xem đoạn mã nguồn dưới đây. Trong đó ta vẫn thực hiện phép nhân ma trận với ma trận, tuy nhiên lần này ta tách thành từng minibatch 64 cột.

timer.start()
for j in range(0, 256, 64):
    A[:, j:j+64] = np.dot(B, C[:, j:j+64])
timer.stop()
print(f'performance in Gigaflops: block {2 / timer.times[3]:.3f}')
performance in Gigaflops: block 639.766

Có thể thấy quá trình tính toán trên minibatch về cơ bản có hiệu suất gần bằng thực hiện trên toàn ma trận. Tuy nhiên, cần lưu ý rằng Trong Section 7.5 ta sử dụng một loại điều chuẩn phụ thuộc chặt chẽ vào phương sai của minibatch. khi tăng kích thước minibatch, phương sai giảm xuống và cùng với đó là lợi ích của việc thêm nhiễu (noise-injection) cũng giảm theo do phương pháp chuẩn hóa theo batch. Đọc [Ioffe, 2017] để biết chi tiết cách chuyển đổi giá trị và tính các số hạng phù hợp.

11.9.3. Đọc Tập dữ liệu

Hãy xem cách tạo các minibatch từ dữ liệu một cách hiệu quả như thế nào. Trong đoạn mã nguồn dưới ta sử dụng tập dữ liệu được phát triển bởi NASA để kiểm tra tiếng ồn từ các máy bay khác nhau để so sánh các thuật toán tối ưu. Để thuận tiện ta chỉ sử dụng \(1,500\) ví dụ đầu tiên. Tập dữ liệu được tẩy trắng (whitened) để xử lý, tức là với mỗi toạ độ ta trừ đi giá trị trung bình và chuyển đổi giá trị phương sai về \(1\).

#@save
d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat',
                           '76e5be1548fd8222e5074cf0faae75edff8cf93f')
#@save
def get_data_ch11(batch_size=10, n=1500):
    data = np.genfromtxt(d2l.download('airfoil'),
                         dtype=np.float32, delimiter='\t')
    data = (data - data.mean(axis=0)) / data.std(axis=0)
    data_iter = d2l.load_array(
        (data[:n, :-1], data[:n, -1]), batch_size, is_train=True)
    return data_iter, data.shape[1]-1

11.9.4. Lập trình từ đầu

Hãy nhớ lại cách lập trình SGD theo minibatch trong Section 3.2. Trong phần tiếp theo, chúng tôi sẽ trình bày cách lập trình tổng quát hơn một chút. Để thuận tiện, hàm lập trình SGD và các thuật toán tối ưu khác được giới thiệu sau trong chương này sẽ có danh sách tham số giống nhau. Cụ thể, chúng ta thêm trạng thái đầu vào states và đặt siêu tham số trong từ điển hyperparams. Bên cạnh đó, chúng ta sẽ tính giá trị mất mát trung bình của từng minibatch trong hàm huấn luyện, từ đó không cần phải chia gradient cho kích thước batch trong thuật toán tối ưu nữa.

def sgd(params, states, hyperparams):
    for p in params:
        p[:] -= hyperparams['lr'] * p.grad

Tiếp theo, chúng ta lập trình một hàm huấn luyện tổng quát, sử dụng được cho cả các thuật toán tối ưu khác được giới thiệu sau trong chương này. Hàm sẽ khởi tạo một mô hình hồi quy tuyến tính và có thể được sử dụng để huấn luyện mô hình với SGD theo minibatch và các thuật toán khác.

#@save
def train_ch11(trainer_fn, states, hyperparams, data_iter,
               feature_dim, num_epochs=2):
    # Initialization
    w = np.random.normal(scale=0.01, size=(feature_dim, 1))
    b = np.zeros(1)
    w.attach_grad()
    b.attach_grad()
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    # Train
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            with autograd.record():
                l = loss(net(X), y).mean()
            l.backward()
            trainer_fn([w, b], states, hyperparams)
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss),))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')
    return timer.cumsum(), animator.Y[0]

Hãy cùng quan sát quá trình tối ưu của thuật toán hạ gradient theo toàn bộ batch. Ta có thể sử dụng toàn bộ batch bằng cách thiết lập kích thước minibatch bằng 1500 (chính là tổng số mẫu). Kết quả là các tham số mô hình chỉ được cập nhật một lần duy nhất trong mỗi epoch. Có thể thấy không có tiến triển đáng kể nào, sau 6 epoch việc tối ưu bị ngừng trệ.

def train_sgd(lr, batch_size, num_epochs=2):
    data_iter, feature_dim = get_data_ch11(batch_size)
    return train_ch11(
        sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)
gd_res = train_sgd(1, 1500, 10)
loss: 0.254, 0.058 sec/epoch
../_images/output_minibatch-sgd_vn_79a398_17_1.svg

Khi kích thước batch bằng 1, chúng ta sử dụng thuật toán SGD để tối ưu. Để đơn giản hóa việc lập trình, chúng ta cố định tốc độ học bằng một hằng số (có giá trị nhỏ). Trong SGD, các tham số mô hình được cập nhật bất cứ khi nào một mẫu huấn luyện được xử lý. Trong trường hợp này, sẽ có 1500 lần cập nhật trong mỗi epoch. Có thể thấy, sự suy giảm giá trị của hàm mục tiêu chậm lại sau một epoch. Mặc dù cả hai thuật toán cùng xử lý 1500 mẫu trong một epoch, SGD tốn thời gian hơn hạ gradient trong thí nghiệm trên. Điều này là do SGD cập nhật các tham số thường xuyên hơn và kém hiệu quả khi xử lý đơn lẻ từng mẫu.

sgd_res = train_sgd(0.005, 1)
loss: 0.243, 0.257 sec/epoch
../_images/output_minibatch-sgd_vn_79a398_19_1.svg

Cuối cùng, khi kích thước batch bằng 100, chúng ta sử dụng thuật toán SGD theo minibatch để tối ưu. Thời gian cần thiết cho mỗi epoch ngắn hơn thời gian tương ứng của SGD và hạ gradient theo toàn bộ batch.

mini1_res = train_sgd(.4, 100)
loss: 0.247, 0.006 sec/epoch
../_images/output_minibatch-sgd_vn_79a398_21_1.svg

Giảm kích thước batch bằng 10, thời gian cho mỗi epoch tăng vì thực thi tính toán trên mỗi batch kém hiệu quả hơn.

mini2_res = train_sgd(.05, 10)
loss: 0.247, 0.030 sec/epoch
../_images/output_minibatch-sgd_vn_79a398_23_1.svg

Cuối cùng, chúng ta so sánh tương quan thời gian và giá trị hàm mất mát trong bốn thí nghiệm trên. Có thể thấy, dù hội tụ nhanh hơn GD về số mẫu được xử lý, nhưng SGD tốn nhiều thời gian hơn để đạt được cùng giá trị mất mát như GD vì thuật toán này tính toán gradient trên từng mẫu một. Thuật toán SGD theo minibatch có thể cân bằng giữa tốc độ hội tụ và hiệu quả tính toán. Với kích thước minibatch bằng 10, thuật toán này hiệu quả hơn SGD; và với kích thước minibatch bằng 100, thời gian chạy của thuật toán này thậm chí nhanh hơn cả GD.

d2l.set_figsize([6, 3])
d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
         'time (sec)', 'loss', xlim=[1e-2, 10],
         legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])
d2l.plt.gca().set_xscale('log')
../_images/output_minibatch-sgd_vn_79a398_25_0.svg

11.9.5. Lập trình Súc tích

Trong Gluon, chúng ta có thể sử dụng lớp Trainer để gọi các thuật toán tối ưu. Cách này được sử dụng để có thể lập trình một hàm huấn luyện tổng quát. Chúng ta sẽ sử dụng hàm này xuyên suốt các phần tiếp theo của chương.

#@save
def train_concise_ch11(tr_name, hyperparams, data_iter, num_epochs=2):
    # Initialization
    net = nn.Sequential()
    net.add(nn.Dense(1))
    net.initialize(init.Normal(sigma=0.01))
    trainer = gluon.Trainer(net.collect_params(), tr_name, hyperparams)
    loss = gluon.loss.L2Loss()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            with autograd.record():
                l = loss(net(X), y)
            l.backward()
            trainer.step(X.shape[0])
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss),))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.avg():.3f} sec/epoch')

Lặp lại thí nghiệm với kích thước batch bằng 10 sử dụng Gluon cho kết quả tương tự như trên.

data_iter, _ = get_data_ch11(10)
train_concise_ch11('sgd', {'learning_rate': 0.05}, data_iter)
loss: 0.245, 0.028 sec/epoch
../_images/output_minibatch-sgd_vn_79a398_29_1.svg

11.9.6. Tóm tắt

  • Vector hóa tính toán sẽ giúp mã nguồn hiệu quả hơn vì nó giảm chi phí phát sinh từ framework học sâu và tận dụng tính cục bộ của bộ nhớ và vùng nhớ đệm trên CPU và GPU tốt hơn.
  • Tồn tại sự đánh đổi giữa hiệu quả về mặt thống kê của SGD và hiệu quả tính toán của việc xử lý các batch dữ liệu kích thước lớn cùng một lúc.
  • Thuật toán SGD theo minibatch kết hợp cả hai lợi ích trên: hiệu quả tính toán và thống kê.
  • Trong thuật toán đó ta xử lý các batch thu được từ hóan vị ngẫu nhiên của dữ liệu huấn luyện (cụ thể, mỗi mẫu được xử lý chỉ một lần mỗi epoch theo thứ tự ngẫu nhiên).
  • Suy giảm tốc độ học trong quá trình huấn luyện được khuyến khích sử dụng.
  • Nhìn chung, SGD theo minibatch nhanh hơn SGD và hạ gradient về thời gian hội tụ.

11.9.7. Bài tập

  1. Sửa đổi kích thước batch và tốc độ học, quan sát tốc độ suy giảm giá trị của hàm mục tiêu và thời gian cho mỗi epoch.
  2. Đọc thêm tài liệu MXNet và sử dụng hàm set_learning_rate của lớp Trainer để giảm tốc độ học của SGD theo minibatch bằng 1/10 giá trị trước đó sau mỗi epoch.
  3. Hãy so sánh SGD theo minibatch sử dụng một biến thể lấy mẫu có hoàn lại từ tập huấn luyện. Điều gì sẽ xảy ra?
  4. Một ác thần đã sao chép tập dữ liệu của bạn mà không nói cho bạn biết (cụ thể, mỗi quan sát bị lặp lại hai lần và kích thước tập dữ liệu tăng gấp đôi so với ban đầu). Cách hoạt động của các thuật toán hạ gradient, SGD và SGD theo minibatch sẽ thay đổi như thế nào?

11.9.8. Thảo luận

11.9.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
  • Đỗ Trường Giang
  • Nguyễn Văn Quang
  • Phạm Minh Đức
  • Lê Khắc Hồng Phúc
  • Phạm Hồng Vinh
  • Nguyễn Văn Cường