Bài 1: Tensor
Nội dung
Dữ liệu biểu diễn dưới dạng số thực
Thông thường các thuật toán Machine Learning (ML), Deep Learning (DL) chỉ xử lý được dữ liệu dạng số thực nên các dữ liệu đưa vào mô hình thường được chuyển về dạng số thực.
Ảnh màu (rgb) được biểu diễn dưới dạng 1 tensor 3 chiều
Hay dữ liệu dạng chữ (tôi, yêu, hoa,..) cũng được chuyển về dạng vector trước khi cho vào các mô hình, ví dụ như mô hình word2vec.
Với dữ liệu đầu vào dạng số thì các mô hình ML hay DL sẽ thực hiện các phép tính toán, biến đổi để cho ra được output của mô hình. Vậy nên biểu diễn dữ liệu dạng số thực và các phép tính toán trên số thực đó chính là nền tảng cơ bản cho các mô hình AI.
Numpy arrays vs PyTorch tensors.
Numpy là thư viện Python giúp lưu trữ và xử lý các phép tính với dữ liệu dạng số thực. Tuy nhiên, Numpy được viết bằng C/C++ nên tốc độ xử lý và tính toán rất nhanh.
PyTorch tensors có chức năng và mục đích tương tự Numpy arrays nhưng có một vài ưu điểm hơn:
- Thực hiện tính toán nhanh trên GPU, vì các mô hình DL muốn tăng tốc thì cần xử lý qua các GPU nên tensors hỗ trợ tính toán nhanh trên GPU rất cần thiết.
- Các Pytorch tensors có thể lưu lại được đồ thị tính toán nên có thể tính đạo hàm nhanh chóng, phục vụ cho thuật toán backpropagation trong Deep Learning, bài sau học về autograd mình sẽ nói kĩ.
Có bài này so sánh về tốc độ giữa Numpy arrays và Torch tensors, mọi người tham khảo thêm.
Torch Tensors
Số thực là dữ liệu 0D, vector 1D, ma trận 2D còn dữ liệu từ 3D trở đi được gọi là tensor, chi tiết mọi người xem ở đây.
Vector
Để truy cập đến phần tử của vector và sửa phần tử của vector ta dùng chỉ số index. Index sẽ được đánh bắt đầu từ 0 đến phần tử cuối cùng của vector.
Tuy nhiên để truy cập phần tử cuối của vector mình phải dùng
x[x.shape[0] - 1] # x.shape[0] trả về số phần tử trong vector
Khá cồng kềnh, nên để tiện cho mọi người cần lấy phần tử từ cuối lại, Tensors hỗ trợ đánh index từ cuối lại, -1 là phần từ cuối cùng, -2 là phần tử thứ hai từ cuối lại,….
x[-1] # Tương đương với x[x.shape[0] - 1], lấy phần tử cuối cùng
Vậy có cách nào lấy được 1 số phần tử trong vector, ví dụ 3 phần tử cuối cùng, các phần tử index chẵn,..? Có, slicing sẽ giúp mọi người.
Cú pháp sliding là: start:stop:step
Nếu mọi người không truyền gì thì mặc định start=0, stop=x.shape[0] và step=1. Ý tưởng slicing là sẽ lấy từ phần tử index start đến index (stop – 1) với bước nhảy là step.
x = x[:] = x[::] = x[0:x.shape[0]:1] # lấy tất các phần tử trong x
Như ví dụ x[1:5:2] ở trên thì mình sẽ lấy phần tử đầu ở index 1, sau đó lấy phần tử ở index 3, tuy nhiên sẽ không lấy phần tử ở index 5 vì mình chỉ lấy từ index start đến (stop – 1) hay từ 1 -> 4.
x[1:5:2] # output: [2, 4]
Torch tensors không hỗ trợ step âm như python list.
Ma trận
Khác với vector là 1D, ma trận 2D, biểu diễn dưới dạng hàng và cột, kích thước ma trận được quy định là số hàng * số cột, ví dụ ma trận ở dưới \displaystyle A\in\mathbb{R}^{3\times 2}. Ta có thể dùng thuộc tính shape để lấy ra kích thước của A
A.shape # torch.Size([3, 2])
A.shape[0] # 3
Để truy cập đến phần tử trong ma trận mình phải chỉ rõ phần tử đấy ở hàng nào và cột nào. Mình sẽ truyền chỉ số hàng trước, chỉ số cột sau. Chỉ số của hàng và cột cũng đánh bắt đầu từ 0, và cũng có quy tắc -1 như ở phần vector.
Ở ma trận cũng hỗ trợ slicing, tuy nhiên ma trận 2 chiều nên khi slicing thì cần chỉ rõ slice những hàng nào và slide những cột nào.
# A[1:, :1] # Tương đương A[1:A.shape[0]:1, 0:1:1]
Mình nói là slicing cũng truyền hàng trước, cột sau. Phần hàng truyền 1: nên sẽ lấy từ hàng 1 đến hết, còn phần cột truyền :1 nên sẽ hiểu là 0:1 và chỉ lấy cột 0.
A[:, 1] # tensor([2, 4, 6])
Mình nói là slicing cũng truyền hàng trước, cột sau. Phần hàng truyền “:” nên hiểu là “0:A.shape[0]:1” ý là lấy tất các hàng, còn phần cột truyền 1 nên sẽ lấy cột 1. Do đó A[:, 1] lấy ra cột index 1.
Tensor 3D
Với tensor 3D thì thuộc tính shape sẽ cho ra 3 giá trị, tương ứng độ sâu (depth), số hàng, số cột. Để truy cập phần tử thì mình cũng phải chỉ rõ index của depth, hàng và cột. Tương tự để slicing thì mình cũng phải slicing trên cả 3 chiều.
Ý tưởng với tensor n dimension tương tự.
Torch Properties
Dtype
Torch tensors chỉ chứa dữ liệu kiểu số và kiểu bool (True/False). Mỗi torch tensor thuộc 1 kiểu dữ liệu, ở thuộc tính dtype. Đây là danh sách các kiểu dữ liệu torch tensors có thể chứa:
- torch.float32 or torch.float: 32-bit floating-point
- torch.float64 or torch.double: 64-bit, double-precision floating-point
- torch.float16 or torch.half: 16-bit, half-precision floating-point
- torch.int8: signed 8-bit integers
- torch.uint8: unsigned 8-bit integers
- torch.int16 or torch.short: signed 16-bit integers
- torch.int32 or torch.int: signed 32-bit integers
- torch.int64 or torch.long: signed 64-bit integers
- torch.bool: Boolean
Bình thường khi bạn gán giá trị cho tensor thì torch sẽ tự động gán dtype bằng dtype của giá trị có kiểu rộng hơn trong tensor. Ví dụ: các giá trị trong tensor có cả int, float thì dtype của tensor sẽ là float.
points = torch.tensor([7, 8, 10, 6.5])
print(points.dtype) # output: torch.float32
Tuy nhiên bạn cũng có thể khởi tạo kiểu dữ liệu cho tensor.
points = torch.tensor([7, 8, 10, 6])
print(points.dtype) # output: torch.int64
# Gán kiểu dữ liệu cho tensor
points = torch.tensor([7, 8, 10, 6], dtype=torch.short)
print(points.dtype) # output: torch.int16
Hoặc mình cũng có thể chuyển kiểu dữ liệu của tensor đã được khai báo.
points = torch.tensor([7, 8, 10, 6]).short()
points = torch.tensor([7, 8, 10, 6]).to(dtype=torch.short)
Hàm to(dtype=…) sẽ kiểm tra kiểu dữ liệu của tensor và chuyển sang kiểu dữ liệu mới nếu cần thiết. Phần dưới mình sẽ dùng hàm to() để chuyển tensor từ CPU sang GPU.
Torch transpose
Hàm torch.transpose(input, dim0, dim1): Nhận input tensor và sẽ đổi chỗ dim0 và dim1 với nhau.
Ví dụ: với ma trận phép tính transpose sẽ chuyển hàng và cột, cụ thể hàng thứ i của A sẽ thành cột thứ i của A^T và cột thứ j của A sẽ thành hàng thứ j của A^T, do đó A\in\mathbb{R}^{3\times 2} \Rightarrow A^T\in\mathbb{R}^{2\times 3}
Cùng thử nhìn transpose với tensor 3d nhé.
Mọi người thấy mình transpose chiều sâu và chiều hàng, chiều cột giữ nguyên (số cột giữ nguyên). Vì số cột giữ nguyên, nên mọi người thấy các vector hàng ở A và A^T không thay đổi, chỉ đổi vị trí. Và từng cột ở mỗi ma trận trong A được tách ra thành các phần tử cho chiều sâu.
Ngoài ra torch còn hỗ trợ rất nhiều phép tính toán liên quan đến tensor nữa, chi tiết mọi người xem ở đây.
Torch Storage
Phần này cùng xem thực sự Torch lưu trữ tensor như thế nào.
Storage
Thực ra các giá trị trong tensor sẽ được lưu trên 1 vùng nhớ liên tục trên bộ nhớ, được quản lý bởi torch.Storage. Storage là 1 mảng 1 chiều gồm các số có cùng kiểu dữ liệu (ở trên mình biết các giá trị trong 1 tensor cùng kiểu dữ liệu).
Ví dụ mình tạo 1 vector với torch, kiểu dữ liệu mặc định với số nguyên sẽ là torch.int64, hay mỗi phần tử cần 8 bytes để lưu trữ.
x sẽ trỏ đến phần tử đầu tiên, và để lấy phần tử x[i] thì mình sẽ truy cập đến vị trị (x + i * 8). Đây là 1 phần lý do vì sao index mọi người thấy hay bắt đầu từ 0, tại x đã trỏ đến phần tử đầu tiên x[0] rồi, còn x[i] sẽ tiện lấy địa chỉ của phần tử (i+1), thêm nữa mọi người xem ở đây.
Storage 1 chiều thì lưu dữ liệu Torch tensor 2 chiều dạng ma trận như thế nào? Storage xếp hết dữ liệu thành 1 chiều, nối các hàng từ trên xuống dưới lần lượt với nhau cho tới hết.
x trỏ đến phần tử hàng 0, cột 0 (x[0][0]). Phần tử x[i][j] sẽ ở ô nhớ (x+i*col+j), trong đó col là số cột của ma trận, hay x[i][j] = storage[i*col+j]
Ví dụ ma trận trên có 2 hàng, 3 cột, thì phần tử x[1][2] (=6) sẽ ở địa chỉ x+1*3+2 = x+5, để truy cập giá trị x[1][2] qua storage mình dùng storage[5].
x = torch.tensor([[1,2,3],[4,5,6]])
x.storage() # output: 1,2,3,4,5,6
x[1][2] == x.storage()[5] # output: True
Tensor metadata: Size, offset, and stride
Để tensor lấy được giá trị từ storage thì mình cần 1 vài thông tin: size, offset và stride.
- Offset là vị trí bắt đầu lưu giá trị của tensor trong storage.
- Size là kích thước của tensor.
- Stride có số chiều bằng số chiều của Size, ý nghĩa là cần nhảy bao nhiêu phần tử trong storage để được phần tử tiếp theo trong chiều đấy.
Như trong ví dụ dưới thì size hay shape, chính là kích thước ma trận (3×3). Offset = 1, tức là giá trị của tensor này lưu từ index 1 của storage thay vì index 0 như các ví dụ ở trên.
Stride = (3,1) ý là:
- để lấy giá trị ở cột đấy nhưng ở hàng phía dưới, cần nhảy 3 phần tử trên storage, ví dụ: x[1][1] (=3) lưu ở index 5 trên storage, thì x[2][1] (=3) lưu ở vị trí 5 + 3 = 8 trên storage.
- để lấy giá trị ở hàng đấy nhưng ở cột lân cận, cần nhảy 1 phần tử trên storage , ví dụ: x[1][1] (=3) lưu ở index 5 trên storage, thì x[1][2] (=2) lưu ở vị trí 5 + 1 = 6 trên storage.
Rõ ràng có 1 storage và biết được các chỉ số size, offset, stride sẽ lấy lấy được các phần tử trong tensor.
Phần tử x[i][j] sẽ tương ứng với storage[offset +stride[0] * i + stride[1] * j].
Tại sao cần nhiều thông tin như vậy? Tưởng ở trên chỉ cần mỗi số cột là lấy được hết các giá trị của tensor. Câu trả lời là để có thể lưu nhiều tensor cùng trên 1 storage. Cùng xem ví dụ về transpose tensor ở dưới.
Transposing tensor
Torch tensor x và x_t (transpose) sẽ dùng chung 1 storage thay vì phải copy ra 1 vùng nhớ khác.
x = torch.tensor([[3, 1, 2], [4, 1, 7]])
x_t = x.t() # Viết gọn cho x.transpose(0, 1)
id(x.storage()) == id(x_t.storage()) # output: True. Hàm id trả về địa chỉ của x.storage(), mình thấy là x và x_t có cùng storage.
Ví dụ trên mình thấy là x và x_t dùng chung 1 storage. Thuộc tính offset cả 2 đều bằng 0, size thì khác nhau, \displaystyle A\in\mathbb{R}^{2\times 3}, A^T\in\mathbb{R}^{3\times 2}
x.stride() # (3,1)
x_t.stride() # (1,3)
Và stride khác nhau, ở x thì mình cần nhảy 3 phần tử trong storage để đến vị trí cột đấy nhưng ở hàng dưới, x[0][0] = storage[0] = 3, x[1][0] = storage[3] = 4. Tuy nhiên, ở x_t thì mình chỉ cần nhảy 1 phần tử trong storage để đến vị trí cột đấy nhưng ở hàng dưới, x_t[0][0] = storage[0] = 3, x_t[1][0] = storage[1] = 1.
Mình thực hiện phép tính transpose nhưng vẫn dùng chung storage. Ngoài ra, ví dụ như khi mọi người slicing chẳng hạn, thì để dùng chung storage mình sẽ cần thay đổi offset, size, stride.
Contiguous tensors
Một vài phép tính trong Torch tensors chỉ chạy trên contigous tensors, ví dụ view. Để kiểm tra xem tensor có contiguous không mình dùng hàm is_contiguous().
x.is_contiguous() # output: True
x_t.is_contiguous() # output: False
x.view(1, -1) # [3, 1, 2, 4, 1, 7]
x_t.view(1, -1) # RuntimeError
Khi mình khởi tạo 1 tensor x bình thường, thì các giá trị x sẽ được lữu trữ liên tiếp (theo từng hàng, hết hàng xuống hàng dưới) và x[i,j] sẽ tương ứng storage[offset+i*col+j] do đó x sẽ là contiguous tensor, còn khi mình thực hiện transpose thì x_t dùng chung storage với x nên thứ tự index không còn được như mặc định, do đó x_t không phải contiguous tensor.
Mình có thể chuyển 1 tensor không phải contiguous tensor sang contigous tensor bằng hàm contiguous().
x_t_con = x_t.contiguous()
x_t_con.is_contiguous() # Trả về True
x_t_con.storage() # 3 4 1 1 2 7
Mình thấy là giá trị x_t_con và x_t là như nhau, tuy nhiên vùng storage khác nhau và stride sẽ khác nhau.
Torch GPU
Phần trước mình có nói về storage thì mặc định sẽ lưu ở CPU, tuy nhiên Torch cho phép tensor lưu ở GPU để tính toán song song cũng như tăng tốc độ xử lý.
Nếu 1 tensor được lưu ở GPU, thì các phép tính toán sẽ được thực hiện ở GPU.
Để khởi tạo 1 tensor và lưu trên gpu thì mình dùng thuộc tính device.
x_gpu = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]], device='cuda')
Hoặc mình có thể copy 1 tensor từ CPU sang GPU
x = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
x_gpu = x.to(device='cuda')
Mỗi tensor chỉ được lưu trên 1 GPU nhất định nên nếu có nhiều GPU thì phải chỉ rõ lưu trên GPU nào, index GPU cũng bắt đầu từ 0.
x_gpu = x.to(device='cuda:0')
# hoặc
x_gpu = x.cuda(0)
x_gpu = x_gpu + 4 # Thực hiện phép tính trên GPU
Để chuyển ngược lại từ GPU về CPU thì mình dùng
x_cpu = x_gpu.to(device='cpu')
# hoặc
x_cpu = x_gpu.cpu()
Vậy là mình đã đi qua kiến thức cơ bản của Torch tensors, những bài sau mình sẽ dùng tensors để xây các mô hình neural network, CNN,…
Torch Tensor to Numpy Array
Torch cho phép chuyển tensor sang Numpy array. Các thuộc tính về size, shape sẽ được giữ nguyên, type sẽ chuyển từ Torch sang Numpy.
x = torch.tensor([1,2,3])
x_np = x.numpy()
Nếu tensor được lưu trên CPU, Torch tensor và Numpy array sẽ dùng chung vùng nhớ, nên thay đổi giá trị ở 1 biến thì giá trị biến còn lại cũng thay đổi.
x[1] = 0
print(x) # output: [1, 0, 3]
print(x_np) # output: [1, 0, 3]
Nếu tensor được lưu trên GPU thì mọi người sẽ không thể chuyển trực tiếp tensor sang Numpy array được, mà mình cần copy nội dung của tensor sang CPU trước rồi mới chuyển sang Numpy array. Do đó 2 biến trên gpu và np không dùng chung vùng nhớ và sửa 1 biến không ảnh hưởng biến còn lại.
x_gpu = torch.tensor([1, 2, 3], device='cuda')
x_np = x_gpu.numpy() # Error
x_np = x_gpu.cpu().numpy() # ok
x_gpu[1] = 0
print(x_gpu) # output: [1, 0, 3]
print(x_np) # output: [1, 2, 3]
Tương tự, mình có thể chuyển Numpy array sang Torch tensor. Torch tensor sẽ lưu ở CPU và 2 biến trên np và cpu sẽ dùng chung vùng nhớ.
x_np = np.array([1, 2, 3])
x_cpu = torch.from_numpy(x_np)
Vậy là bài này mình đã học các kiến thức cơ bản của Torch Tensors, bài sau mình sẽ học về autograd trong tensors.
Code bài này mọi người lấy ở đây.