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 ndarray
là ndarray
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ả shape
và size
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}\) và \(\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ủax
và y
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.]]))
Vì a
và b
là các ma trận có kích thước lần lượt là
\(3\times1\) và \(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 ndarray
mà y
đã 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 array
và
asnumpy
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 NumPyndarray
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¶
- Chạy đoạn mã nguồn trong mục này. Thay đổi điều kiện mệnh đề
x == y
sangx < y
hoặcx > y
, sau đó kiểm tra dạng củandarray
nhận được. - 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ácndarray
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