2.1. Thao tác với Dữ liệu

Muốn thực hiện bất cứ điều gì, chúng ta đều cần một cách nào đó để lưu trữ và thao tác với dữ liệu. Thường sẽ có hai điều quan trọng chúng ta cần làm với dữ liệu: (i) thu thập; và (ii) xử lý sau khi đã có dữ liệu trên máy tính. Sẽ thật vô nghĩa khi thu thập dữ liệu mà không có cách để lưu trữ nó, vậy nên trước tiên hãy cùng làm quen với dữ liệu tổng hợp. Để bắt đầu, chúng tôi giới thiệu mảng \(n\) chiều (ndarray) – công cụ chính trong MXNET để lưu trữ và biến đổi dữ liệu. Trong MXNet, ndarray là một lớp và mỗi thực thể của lớp đó là “một ndarray”.

Nếu bạn từng làm việc với NumPy, gói tính toán phổ biến nhất trong Python, bạn sẽ thấy mục này quen thuộc. Việc này là có chủ đích. Chúng tôi thiết kế ndarray trong MXNet là một dạng mở rộng của ndarray trong NumPy với một vài tính năng đặc biệt. Thứ nhất, ndarray trong MXNet hỗ trợ tính toán phi đồng bộ trên CPU, GPU, và các kiến trúc phân tán đám mây, trong khi NumPy chỉ hỗ trợ tính toán trên CPU. Thứ hai, ndaray trong MXNet hỗ trợ tính vi phân tự động. Những tính chất này khiến ndarray của MXNet phù hợp với học sâu. Thông qua cuốn sách, nếu không nói gì thêm, chúng ta ngầm hiểu ndarrayndarray của MXNet.

2.1.1. Bắt đầu

Trong mục này, mục tiêu của chúng tôi là trang bị cho bạn các kiến thức toán cơ bản và cài đặt các công cụ tính toán mà bạn sẽ xây dựng dựa trên nó xuyên suốt cuốn sách này. Đừng lo nếu bạn gặp khó khăn với các khái niệm toán khó hiểu hoặc các hàm trong thư viện tính toán. Các mục tiếp theo sẽ nhắc lại những khái niệm này trong từng ngữ cảnh kèm theo ví dụ thực tiễn. Mặt khác, nếu bạn đã có kiến thức nền tảng và muốn đi sâu hơn vào các nội dung toán, bạn có thể bỏ qua mục này.

Để bắt đầu, ta cần khai báo mô-đun np (numpy) và npx (numpy_extension) từ MXNet. Ở đây, mô-đun np bao gồm các hàm hỗ trợ bởi NumPy, trong khi mô-đun npx chứa một tập các hàm mở rộng được phát triển để hỗ trợ học sâu trong một môi trường giống với NumPy. Khi sử dụng ndarray, ta luôn cần gọi hàm set_np: điều này nhằm đảm bảo sự tương thích của việc xử lý ndarray bằng các thành phần khác của MXNet.

from mxnet import np, npx
npx.set_np()

Một ndarray biểu diễn một mảng (có thể đa chiều) các giá trị số. Với một trục, một ndarray tương ứng (trong toán) với một vector. Với hai trục, một ndarray tương ứng với một ma trận. Các mảng với nhiều hơn hai trục không có tên toán học cụ thể–chúng được gọi chung là tensor.

Để bắt đầu, chúng ta sử dụng arange để tạo một vector hàng x chứa \(12\) số nguyên đầu tiên bắt đầu từ \(0\), nhưng được khởi tạo mặc định dưới dạng số thực. Mỗi giá trị trong một ndarray được gọi là một phần tử của ndarray đó. Như vậy, có \(12\) phần tử trong ndarray x. Nếu không nói gì thêm, một ndarray mới sẽ được lưu trong bộ nhớ chính và được tính toán trên CPU.

x = np.arange(12)
x
array([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11.])

Chúng ta có thể lấy kích thước (độ dài theo mỗi trục) của ndarray bằng thuộc tính shape.

x.shape
(12,)

Nếu chỉ muốn biết tổng số phần tử của một ndarray, nghĩa là tích của tất cả các thành phần trong shape, ta có thể sử dụng thuộc tính size. Vì ta đang làm việc với một vector, cả shapesize của nó đều chứa cùng một phần tử duy nhất.

x.size
12

Để thay đổi kích thước của một ndarray mà không làm thay đổi số lượng phần tử cũng như giá trị của chúng, ta có thể gọi hàm reshape. Ví dụ, ta có thể biến đổi ndarray x trong ví dụ trên, từ một vector hàng với kích thước (\(12\),) sang một ma trận với kích thước (\(3\), \(4\)). ndarray mới này chứa \(12\) phần tử y hệt, nhưng được xem như một ma trận với \(3\) hàng và \(4\) cột. Mặc dù kích thước thay đổi, các phần tử của x vẫn giữ nguyên. Chú ý rằng size giữ nguyên khi thay đổi kích thước.

x = x.reshape(3, 4)
x
array([[ 0.,  1.,  2.,  3.],
       [ 4.,  5.,  6.,  7.],
       [ 8.,  9., 10., 11.]])

Việc chỉ định cụ thể mọi chiều khi thay đổi kích thước là không cần thiết. Nếu kích thước mong muốn là một ma trận với kích thước (chiều_cao, chiều_rộng), thì sau khi biết chiều_rộng, chiều_cao có thể được ngầm suy ra. Tại sao ta lại cần phải tự làm phép tính chia? Trong ví dụ trên, để có được một ma trận với \(3\) hàng, chúng ta phải chỉ định rõ rằng nó có \(3\) hàng và \(4\) cột. May mắn thay, ndarray có thể tự động tính một chiều từ các chiều còn lại. Ta có thể dùng chức năng này bằng cách đặt -1 cho chiều mà ta muốn ndarray tự suy ra. Trong trường hợp vừa rồi, thay vì gọi x.reshape(3, 4), ta có thể gọi x.reshape(-1, 4) hoặc x.reshape(3, -1).

Phương thức empty lấy một đoạn bộ nhớ và trả về một ma trận mà không thay đổi các giá trị sẵn có tại đoạn bộ nhớ đó. Việc này có hiệu quả tính toán đáng kể nhưng ta phải cẩn trọng bởi các phần tử đó có thể chứa bất kỳ giá trị nào, kể cả các số rất lớn!

np.empty((3, 4))
array([[1.1001436e-26, 4.5648699e-41, 5.7875015e-19, 3.0751495e-41],
       [0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00],
       [0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00]])

Thông thường ta muốn khởi tạo các ma trận với các giá trị bằng không, bằng một, bằng hằng số nào đó hoặc bằng các mẫu ngẫu nhiên lấy từ một phân phối cụ thể. Ta có thể tạo một ndarray biểu diễn một tensor với tất cả các phần tử bằng \(0\) và có kích thước (\(2\), \(3\), \(4\)) như sau:

np.zeros((2, 3, 4))
array([[[0., 0., 0., 0.],
        [0., 0., 0., 0.],
        [0., 0., 0., 0.]],

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

Tương tự, ta có thể tạo các tensor với các phần tử bằng 1 như sau:

np.ones((2, 3, 4))
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.]]])

Ta thường muốn lấy mẫu ngẫu nhiên cho mỗi phần tử trong một ndarray từ một phân phối xác suất. Ví dụ, khi xây dựng các mảng để chứa các tham số của một mạng nơ-ron, ta thường khởi tạo chúng với các giá trị ngẫu nhiên. Đoạn mã dưới đây tạo một ndarray có kích thước (\(3\), \(4\)) với các phần tử được lấy mẫu ngẫu nhiên từ một phân phối Gauss (phân phối chuẩn) với trung bình bằng \(0\) và độ lệch chuẩn \(1\).

np.random.normal(0, 1, size=(3, 4))
array([[ 2.2122064 ,  1.1630787 ,  0.7740038 ,  0.4838046 ],
       [ 1.0434405 ,  0.29956347,  1.1839255 ,  0.15302546],
       [ 1.8917114 , -1.1688148 , -1.2347414 ,  1.5580711 ]])

Ta cũng có thể khởi tạo giá trị cụ thể cho mỗi phần tử trong ndarray mong muốn bằng cách đưa vào một mảng Python (hoặc mảng của mảng) chứa các giá trị số. Ở đây, mảng ngoài cùng tương ứng với trục \(0\), và mảng bên trong tương ứng với trục \(1\).

np.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
array([[2., 1., 4., 3.],
       [1., 2., 3., 4.],
       [4., 3., 2., 1.]])

2.1.2. Phép toán

Cuốn sách này không nói về kỹ thuật phần mềm. Chúng tôi không chỉ hứng thú với việc đơn giản đọc và ghi dữ liệu vào/từ các mảng mà còn muốn thực hiện các phép toán trên các mảng này. Một vài phép toán đơn giản và hữu ích nhất là các phép toán tác động lên từng phần tử (elementwise). Các phép toán này hoạt động như những phép toán chuẩn trên số vô hướng áp dụng lên từng phần tử của mảng. Với những hàm nhận hai mảng đầu vào, phép toán theo từng thành phần được áp dụng trên từng cặp phần tử tương ứng của hai mảng. Ta có thể tạo một hàm theo từng phần tử từ một hàm bất kỳ ánh xạ từ một số vô hướng tới một số vô hướng.

Trong toán học, ta ký hiệu một toán tử đơn ngôi vô hướng (lấy một đầu vào) bởi \(f: \mathbb{R} \rightarrow \mathbb{R}\). Điều này nghĩa là hàm số ánh xạ từ một số thực bất kỳ (\(\mathbb{R}\)) sang một số thực khác. Tương tự, ta ký hiệu một toán tử hai ngôi vô hướng (lấy hai đầu vào, trả về một đầu ra) bởi \(f: \mathbb{R}, \mathbb{R} \rightarrow \mathbb{R}\). Cho trước hai vector bất kỳ \(\mathbf{u}\)\(\mathbf{v}\) với cùng kích thước, và một toán tử hai ngôi \(f\), ta có thể tính được một vector \(\mathbf{c} = F(\mathbf{u},\mathbf{v})\) bằng cách tính \(c_i \gets f(u_i, v_i)\) cho mọi \(i\) với \(c_i, u_i\), và \(v_i\) là các phần tử thứ \(i\) của vector \(\mathbf{c}, \mathbf{u}\), và \(\mathbf{v}\). Ở đây, chúng ta tạo một hàm trả về vector \(F: \mathbb{R}^d, \mathbb{R}^d \rightarrow \mathbb{R}^d\) bằng cách áp dụng hàm \(f\) lên từng phần tử.

Trong MXNet, các phép toán tiêu chuẩn (+, -, *, /, và **) là các phép toán theo từng phần tử trên các tensor đồng kích thước bất kỳ. Ta có thể gọi những phép toán theo từng phần tử lên hai tensor đồng kích thước. Trong ví dụ dưới đây, các dấu phẩy được sử dụng để tạo một tuple \(5\) phần tử với mỗi phần tử là kết quả của một phép toán theo từng phần tử.

x = np.array([1, 2, 4, 8])
y = np.array([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y  # The ** operator is exponentiation
(array([ 3.,  4.,  6., 10.]),
 array([-1.,  0.,  2.,  6.]),
 array([ 2.,  4.,  8., 16.]),
 array([0.5, 1. , 2. , 4. ]),
 array([ 1.,  4., 16., 64.]))

Rất nhiều các phép toán khác có thể được áp dụng theo từng phần tử, bao gồm các phép toán đơn ngôi như hàm mũ cơ số \(e\).

np.exp(x)
array([2.7182817e+00, 7.3890562e+00, 5.4598148e+01, 2.9809580e+03])

Ngoài các phép tính theo từng phần tử, ta cũng có thể thực hiện các phép toán đại số tuyến tính, bao gồm tích vô hướng của hai vector và phép nhân ma trận. Chúng ta sẽ giải thích những điểm quan trọng của đại số tuyến tính (mà không cần kiến thức nền tảng) trong Section 2.3.

Ta cũng có thể nối nhiều ndarray với nhau, xếp chồng chúng lên nhau để tạo ra một ndarray lớn hơn. Ta chỉ cần cung cấp một danh sách các ndarray và khai báo chúng được nối theo trục nào. Ví dụ dưới đây thể hiện cách nối hai ma trận theo hàng (trục \(0\), phần tử đầu tiên của kích thước) và theo cột (trục \(1\), phần tử thứ hai của kích thước). Ta có thể thấy rằng, cách thứ nhất tạo một ndarray với độ dài trục \(0\) (\(6\)) bằng tổng các độ dài trục \(0\) của hai ndarray đầu vào (\(3 + 3\)); trong khi cách thứ hai tạo một ndarray với độ dài trục \(1\) (\(8\)) bằng tổng các độ dài trục \(1\) của hai ndarray đầu vào (\(4 + 4\)).

x = np.arange(12).reshape(3, 4)
y = np.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
np.concatenate([x, y], axis=0), np.concatenate([x, y], axis=1)
(array([[ 0.,  1.,  2.,  3.],
        [ 4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11.],
        [ 2.,  1.,  4.,  3.],
        [ 1.,  2.,  3.,  4.],
        [ 4.,  3.,  2.,  1.]]),
 array([[ 0.,  1.,  2.,  3.,  2.,  1.,  4.,  3.],
        [ 4.,  5.,  6.,  7.,  1.,  2.,  3.,  4.],
        [ 8.,  9., 10., 11.,  4.,  3.,  2.,  1.]]))

Đôi khi, ta muốn tạo một ndarray nhị phân thông qua các mệnh đề logic. Lấy x == y làm ví dụ. Với mỗi vị trí, nếu giá trị củaxy tại vị trí đó bằng nhau thì phần tử tương ứng trong ndarray mới lấy giá trị \(1\), nghĩa là mệnh đề logic x == y là đúng tại vị trí đó; ngược lại vị trí đó lấy giá trị \(0\).

x == y
array([[False,  True, False,  True],
       [False, False, False, False],
       [False, False, False, False]])

Lấy tổng mọi phần tử trong một ndarray tạo ra một ndarray với chỉ một phần tử.

x.sum()
array(66.)

Ta cũng có thể thay x.sum() bởi np.sum(x).

2.1.3. Cơ chế Lan truyền

Trong mục trên, ta đã thấy cách thực hiện các phép toán theo từng phần tử với hai ndarray đồng kích thước. Trong những điều kiện nhất định, thậm chí khi kích thước khác nhau, ta vẫn có thể thực hiện các phép toán theo từng phần tử bằng cách sử dụng cơ chế lan truyền (broadcasting mechanism). Cơ chế này hoạt động như sau: Thứ nhất, mở rộng một hoặc cả hai mảng bằng cách lặp lại các phần tử một cách hợp lý sao cho sau phép biến đổi này, hai ndarray có cùng kích thước. Thứ hai, thực hiện các phép toán theo từng phần tử với hai mảng mới này.

Trong hầu hết các trường hợp, chúng ta lan truyền một mảng theo trục có độ dài ban đầu là \(1\), như ví dụ dưới đây:

a = np.arange(3).reshape(3, 1)
b = np.arange(2).reshape(1, 2)
a, b
(array([[0.],
        [1.],
        [2.]]),
 array([[0., 1.]]))

ab là các ma trận có kích thước lần lượt là \(3\times1\)\(1\times2\), kích thước của chúng không khớp nếu ta muốn thực hiện phép cộng. Ta lan truyền các phần tử của cả hai ma trận thành các ma trận \(3\times2\) như sau: lặp lại các cột trong ma trận a và các hàng trong ma trận b trước khi cộng chúng theo từng phần tử.

a + b
array([[0., 1.],
       [1., 2.],
       [2., 3.]])

2.1.4. Chỉ số và Cắt chọn mảng

Cũng giống như trong bất kỳ mảng Python khác, các phần tử trong một ndarray có thể được truy cập theo chỉ số. Tương tự, phần tử đầu tiên có chỉ số \(0\) và khoảng được cắt chọn bao gồm phần tử đầu tiên nhưng không tính phần tử cuối cùng. Và trong các danh sách Python tiêu chuẩn, chúng ta có thể truy cập các phần tử theo vị trí đếm ngược từ cuối danh sách bằng cách sử dụng các chỉ số âm.

Vì vậy, [-1] chọn phần tử cuối cùng và [1:3] chọn phần tử thứ hai và phần tử thứ ba như sau:

x[-1], x[1:3]
(array([ 8.,  9., 10., 11.]),
 array([[ 4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11.]]))

Ngoài việc đọc, chúng ta cũng có thể viết các phần tử của ma trận bằng cách chỉ định các chỉ số.

x[1, 2] = 9
x
array([[ 0.,  1.,  2.,  3.],
       [ 4.,  5.,  9.,  7.],
       [ 8.,  9., 10., 11.]])

Nếu chúng ta muốn gán cùng một giá trị cho nhiều phần tử, chúng ta chỉ cần trỏ đến tất cả các phần tử đó và gán giá trị cho chúng. Chẳng hạn, [0:2 ,:] truy cập vào hàng thứ nhất và thứ hai, trong đó : lấy tất cả các phần tử dọc theo trục \(1\) (cột). Ở đây chúng ta đã thảo luận về cách truy cập vào ma trận, nhưng tất nhiên phương thức này cũng áp dụng cho các vector và tensor với nhiều hơn \(2\) chiều.

x[0:2, :] = 12
x
array([[12., 12., 12., 12.],
       [12., 12., 12., 12.],
       [ 8.,  9., 10., 11.]])

2.1.5. Tiết kiệm Bộ nhớ

Ở ví dụ trước, mỗi khi chạy một phép tính, chúng ta sẽ cấp phát bộ nhớ mới để lưu trữ kết quả của lượt chạy đó. Cụ thể hơn, nếu viết y = x + y, ta sẽ ngừng tham chiếu đến ndarrayy đã chỉ đến trước đó và thay vào đó gán y vào bộ nhớ được cấp phát mới. Trong ví dụ tiếp theo, chúng ta sẽ minh họa việc này với hàm id() của Python - hàm cung cấp địa chỉ chính xác của một đối tượng được tham chiếu trong bộ nhớ. Sau khi chạy y = y + x, chúng ta nhận ra rằng id(y) chỉ đến một địa chỉ khác. Đó là bởi vì Python trước hết sẽ tính y + x, cấp phát bộ nhớ mới cho kết quả trả về và gán y vào địa chỉ mới này trong bộ nhớ.

before = id(y)
y = y + x
id(y) == before
False

Đây có thể là điều không mong muốn vì hai lý do. Thứ nhất, không phải lúc nào chúng ta cũng muốn cấp phát bộ nhớ không cần thiết. Trong học máy, ta có thể có đến hàng trăm megabytes tham số và cập nhật tất cả chúng nhiều lần mỗi giây, và thường thì ta muốn thực thi các cập nhật này tại chỗ. Thứ hai, chúng ta có thể trỏ đến cùng tham số từ nhiều biến khác nhau. Nếu không cập nhật tại chỗ, các bộ nhớ đã bị loại bỏ sẽ không được giải phóng, dẫn đến khả năng một số chỗ trong mã nguồn sẽ vô tình tham chiếu lại các tham số cũ.

May mắn thay, ta có thể dễ dàng thực hiện các phép tính tại chỗ với MXNet. Chúng ta có thể gán kết quả của một phép tính cho một mảng đã được cấp phát trước đó bằng ký hiệu cắt chọn (slice notation), ví dụ, y[:] = <expression>. Để minh họa khái niệm này, đầu tiên chúng ta tạo một ma trận mới z có cùng kích thước với ma trận y, sử dụng zeros_like để gán giá trị khởi tạo bằng \(0\).

z = np.zeros_like(y)
print('id(z):', id(z))
z[:] = x + y
print('id(z):', id(z))
id(z): 139910709656624
id(z): 139910709656624

Nếu các tính toán tiếp theo không tái sử dụng giá trị của x, chúng ta có thể viết x[:] = x + y hoặc x += y để giảm thiểu việc sử dụng bộ nhớ không cần thiết trong quá trình tính toán.

before = id(x)
x += y
id(x) == before
True

2.1.6. Chuyển đổi sang các Đối Tượng Python Khác

Chuyển đổi một MXNet ndarray sang NumPy ndarray hoặc ngược lại là khá đơn giản. Tuy nhiên, kết quả của phép chuyển đổi này không chia sẻ bộ nhớ với đối tượng cũ. Điểm bất tiện này tuy nhỏ nhưng lại khá quan trọng: khi bạn thực hiện các phép tính trên CPU hoặc GPUs, bạn không muốn MXNet dừng việc tính toán để chờ xem liệu gói Numpy của Python có sử dụng cùng bộ nhớ đó để làm việc khác không. Hàm arrayasnumpy sẽ giúp bạn giải quyết vấn đề này.

a = x.asnumpy()
b = np.array(a)
type(a), type(b)
(numpy.ndarray, mxnet.numpy.ndarray)

Để chuyển đổi một mảng ndarray chứa một phần tử sang số vô hướng Python, ta có thể gọi hàm item hoặc các hàm có sẵn trong Python.

a = np.array([3.5])
a, a.item(), float(a), int(a)
(array([3.5]), 3.5, 3.5, 3)

2.1.7. Tổng kết

  • MXNet ndarray là phần mở rộng của NumPy ndarray với một số ưu thế vượt trội giúp cho nó phù hợp với học sâu.
  • MXNet ndarray cung cấp nhiều chức năng bao gồm các phép toán cơ bản, cơ chế lan truyền (broadcasting), chỉ số (indexing), cắt chọn (slicing), tiết kiệm bộ nhớ và khả năng chuyển đổi sang các đối tượng Python khác.

2.1.8. Bài tập

  1. Chạy đoạn mã nguồn trong mục này. Thay đổi điều kiện mệnh đề x == y sang x < y hoặc x > y, sau đó kiểm tra dạng của ndarray nhận được.
  2. Thay hai ndarray trong phép tính theo từng phần tử ở phần cơ chế lan truyền (broadcasting mechanism) với các ndarray có kích thước khác, ví dụ như tensor ba chiều. Kết quả có giống như bạn mong đợi hay không?

2.1.9. Thảo luận

2.1.10. 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
  • Vũ Hữu Tiệp
  • Lê Khắc Hồng Phúc
  • Phạm Hồng Vinh
  • Trần Thị Hồng Hạnh
  • Phạm Minh Đức
  • Lê Đàm Hồng Lộc
  • Nguyễn Lê Quang Nhật