Tự Học Data Science · 28/09/2023 0

Chương 2 – Bài 3 – Trính toán trên NumPy Arrays – Universal Functions

Tốc độ chậm của vòng lặp

Phiên bản mặc định của Python (gọi là CPython) thực hiện một số thao tác rất chậm.Điều này một phần là do tính đa dạng và phiên dịch của ngôn ngữ: sự linh hoạt của loại dữ liệu khiến những chuỗi thao tác không thể được biên dịch thành mã máy hiệu suất cao như trong ngôn ngữ C và Fortran.Gần đây đã có nhiều nỗ lực để khắc phục điểm yếu này: các ví dụ nổi tiếng là dự án PyPy, phiên bản dịch Just-in-time của Python; dự án Cython, chuyển đổi mã Python thành mã C có thể biên dịch; và dự án Numba, chuyển đổi đoạn mã Python thành mã byte LLVM nhanh chóng.Mỗi phương pháp này có ưu điểm và nhược điểm riêng, nhưng có thể khẳng định rằng không một phương pháp nào trong ba phương pháp này đã vượt qua được giới hạn và sự phổ biến của Engine CPython tiêu chuẩn.

Sự chậm chạp tương đối của Python thường hiển thị trong những tình huống mà nhiều hoạt động nhỏ được lặp đi lặp lại – ví dụ như lặp qua các mảng để thao tác trên mỗi phần tử. Ví dụ, hãy tưởng tượng rằng chúng ta có một mảng giá trị và chúng ta muốn tính nghịch đảo của mỗi giá trị.Một cách tiếp cận trực tiếp có thể trông giống như sau:

import numpy as np
np.random.seed(0)
def compute_reciprocals(values):
    output = np.empty(len(values))
    for i in range(len(values)):
        output[i] = 1.0 / values[i]
    return output
        values = np.random.randint(1, 10, size=5)
compute_reciprocals(values)
array([ 0.16666667,  1.        ,  0.25      ,  0.25      ,  0.125     ])

Việc triển khai này có lẽ cảm thấy khá tự nhiên đối với ai đó từ nền tảng C hoặc Java.

big_array = np.random.randint(1, 100, size=1000000)
%timeit 
compute_reciprocals(big_array)
1 loop, best of 3: 2.91 s per loop

Việc tính toán hàng triệu phép tính và lưu kết quả mất vài giây! Khi thậm chí điện thoại di động cũng có tốc độ xử lý đo bằng Giga-FLOPS (tức là hàng tỷ phép tính số học mỗi giây), điều này dường như vô cùng chậm chạp.Thực ra, chỗ chặn ở đây không phải là các phép tính mà là kiểm tra kiểu dữ liệu và các hàm được gọi ở mỗi vòng lặp trong CPython.Mỗi khi tính nghịch đảo, Python trước tiên kiểm tra kiểu đối tượng và tìm kiếm động các hàm phù hợp để sử dụng với kiểu đó.Nếu chúng ta đang làm việc trong mã máy đã biên dịch, kiểu dữ liệu này sẽ được xác định trước khi mã chạy và kết quả có thể được tính toán nhanh chóng hơn nhiều.

Giới thiệu UFuncs

Đối với nhiều loại hoạt động, NumPy cung cấp một giao diện thuận tiện cho chính loại thao tác được cấu trúc và biên dịch này. Đây được gọi là một hoạt động được vectorized.Điều này có thể được thực hiện bằng cách đơn giản thực hiện một hoạt động trên mảng, sau đó sẽ được áp dụng cho mỗi phần tử.Phương pháp vectorized này được thiết kế để đẩy vòng lặp vào lớp được biên dịch mà đằng sau NumPy, dẫn đến thực thi nhanh hơn nhiều.

So sánh kết quả của hai cái dưới đây:

print(compute_reciprocals(values))
print(1.0 / values)
[ 0.16666667  1.          0.25        0.25        0.125     ][ 0.16666667  1.          0.25        0.25        0.125     ]

Nhìn vào thời gian thực thi cho mảng lớn của chúng ta, chúng ta thấy rằng nó hoàn thành nhanh hơn nhiều so với vòng lặp Python:

%timeit (1.0 / big_array)
100 loops, best of 3: 4.6 ms per loop

Các phép toán vector hóa trong NumPy được thực hiện thông qua ufuncs, mục đích chính của chúng là thực hiện nhanh chóng những phép toán lặp lại trên các giá trị trong mảng NumPy.Ufuncs rất linh hoạt – trước đó chúng ta đã thấy một phép toán giữa một số vô hướng và một mảng, nhưng chúng ta cũng có thể thực hiện phép toán giữa hai mảng:

np.arange(5) / np.arange(1, 6)
array([ 0.        ,  0.5       ,  0.66666667,  0.75      ,  0.8       ])

Và các hoạt động ufunc không chỉ giới hạn cho mảng một chiều – chúng cũng có thể thao tác trên các mảng nhiều chiều:

x = np.arange(9).reshape((3, 3))
2 ** x
array([[  1,   2,   4],
       [  8,  16,  32],
       [ 64, 128, 256]])

Công việc tính toán sử dụng vectorization thông qua ufuncs thường rất hiệu quả hơn so với việc triển khai tương ứng bằng vòng lặp Python, đặc biệt là khi các mảng phát triển theo kích thước.

Khám phá UFuncs của NumPy

Ufuncs tồn tại dưới hai hình thức: ufuncs một ngôi, hoạt động trên một đầu vào duy nhất, và ufuncs hai ngôi, hoạt động trên hai đầu vào.Chúng ta sẽ thấy ví dụ về cả hai loại hàm này ở đây.

Phép toán mảng

Người dùng cảm thấy rất tự nhiên khi sử dụng ufuncs của NumPy vì chúng tận dụng các toán tử số học cơ bản của Python. Phép cộng, trừ, nhân và chia tiêu chuẩn đều có thể được sử dụng:

x = np.arange(4)
print("x     =", x)
print("x + 5 =", x + 5)
print("x - 5 =", x - 5)
print("x * 2 =", x * 2)
print("x / 2 =", x / 2)
print("x // 2 =", x // 2)  # floor division
x     = [0 1 2 3]
x + 5 = [5 6 7 8]
x - 5 = [-5 -4 -3 -2]
x * 2 = [0 2 4 6]
x / 2 = [ 0.   0.5  1.   1.5]
x // 2 = [0 0 1 1]

Có một ufunc nhị phân để phủ định, và một toán tử ** để tính lũy thừa, và một toán tử % để tính phần dư:

print("-x     = ", -x)print("x ** 2 = ", x ** 2)print("x % 2  = ", x % 2)
-x     =  [ 0 -1 -2 -3]
x ** 2 =  [0 1 4 9]
x % 2  =  [0 1 0 1]

Ngoài ra, chúng có thể được kết hợp lại với nhau theo ý muốn, và thứ tự thực hiện mặc định được tôn trọng:

-(0.5*x + 1) ** 2
array([-1.  , -2.25, -4.  , -6.25])

Mỗi phép toán số học này đều chỉ là các gói tiện ích đơn giản xung quanh các hàm cụ thể được tích hợp trong NumPy; ví dụ, toán tử + là một gói tiện ích cho hàm add:

np.add(x, 2)
array([2, 3, 4, 5])

Bảng sau liệt kê các toán tử số học được triển khai trong NumPy:

Bên cạnh đó, còn tồn tại các toán tử Boolean/bitwise; chúng ta sẽ khám phá những toán tử này trong So sánh, Mặt nạ và Logic Boolean.

Giá trị tuyệt đối

Giống như NumPy hiểu được các toán tử số học có sẵn trong Python, nó cũng hiểu được hàm giá trị tuyệt đối có sẵn trong Python:

x = np.array([-2, -1, 0, 1, 2])abs(x)
array([2, 1, 0, 1, 2])

Phương thức NumPy tương ứng là np.absolute, cũng có thể sử dụng với bí danh np.abs:

np.absolute(x)
array([2, 1, 0, 1, 2])
np.abs(x)
array([2, 1, 0, 1, 2])

Ở đây, ufunc này cũng có thể xử lý dữ liệu phức tạp, trong đó giá trị tuyệt đối trả về độ lớn:

x = np.array([3 - 4j, 4 - 3j, 2 + 0j, 0 + 1j])
np.abs(x)
array([ 5.,  5.,  2.,  1.])

Các hàm lượng giác

NumPy cung cấp một số lượng lớn các ufuncs hữu ích và một số trong số những ufuncs quan trọng nhất cho nhà khoa học dữ liệu là các hàm lượng giác.Chúng ta sẽ bắt đầu bằng việc xác định một mảng góc:

theta = np.linspace(0, np.pi, 3)

Bây giờ chúng ta có thể tính toán một số hàm lượng giác trên các giá trị này:

print("theta      = ", theta)
print("sin(theta) = ", np.sin(theta))
print("cos(theta) = ", np.cos(theta))
print("tan(theta) = ", np.tan(theta))
theta      =  [ 0.          1.57079633  3.14159265]
sin(theta) =  [  0.00000000e+00   1.00000000e+00   1.22464680e-16]
cos(theta) =  [  1.00000000e+00   6.12323400e-17  -1.00000000e+00]
tan(theta) =  [  0.00000000e+00   1.63312394e+16  -1.22464680e-16]

Các giá trị được tính đến độ chính xác máy tính, đó là lý do tại sao các giá trị mà nên là 0 không luôn đạt chính xác 0.Các hàm lượng giác nghịch đảo cũng có sẵn:

x = [-1, 0, 1]
print("x         = ", x)
print("arcsin(x) = ", np.arcsin(x))
print("arccos(x) = ", np.arccos(x))
print("arctan(x) = ", np.arctan(x))
x         =  [-1, 0, 1]
arcsin(x) =  [-1.57079633  0.          1.57079633]
arccos(x) =  [ 3.14159265  1.57079633  0.        ]
arctan(x) =  [-0.78539816  0.          0.78539816]

Mũ và logarithm

Một loại hoạt động phổ biến khác có sẵn trong một NumPy ufunc là các hàm mũ:

x = [1, 2, 3]
print("x     =", x)
print("e^x   =", np.exp(x))
print("2^x   =", np.exp2(x))
print("3^x   =", np.power(3, x))
x     = [1, 2, 3]
e^x   = [  2.71828183   7.3890561   20.08553692]
2^x   = [ 2.  4.  8.]
3^x   = [ 3  9 27]

Phản nghịch của các số mũ, các hàm logarit, cũng có sẵn.

x = [1, 2, 4, 10]print("x        =", x)print("ln(x)    =", np.log(x))print("log2(x)  =", np.log2(x))print("log10(x) =", np.log10(x))
x        = [1, 2, 4, 10]
ln(x)    = [ 0.          0.69314718  1.38629436  2.30258509]
log2(x)  = [ 0.          1.          2.          3.32192809]
log10(x) = [ 0.          0.30103     0.60205999  1.        ]

Cũng có một số phiên bản chuyên biệt khác rất hữu ích để đảm bảo độ chính xác với đầu vào rất nhỏ:

x = [0, 0.001, 0.01, 0.1]
print("exp(x) - 1 =", np.expm1(x))
print("log(1 + x) =", np.log1p(x))
exp(x) - 1 = [ 0.          0.0010005   0.01005017  0.10517092]
log(1 + x) = [ 0.          0.0009995   0.00995033  0.09531018]

Khi x rất nhỏ, các hàm này trả về các giá trị chính xác hơn so với nếu sử dụng np.log hoặc np.exp gốc.

Hàm ufuncs đặc biệt

NumPy có nhiều ufunc khác nhau, bao gồm các hàm lượng giác hyperbolic, các phép toán bitwise, các toán tử so sánh, chuyển đổi từ radian sang độ, làm tròn và dư phần, và nhiều tính năng khác.

Một nguồn tư liệu khác tuyệt vời cho những ufunc chuyên môn và khó hiểu hơn là các nhánh phụ scipy.special.Nếu bạn muốn tính toán một số hàm toán học khó hiểu trên dữ liệu của bạn, có khả năng nó được thực hiện trong scipy.special.Có quá nhiều chức năng để liệt kê tất cả, nhưng đoạn mã sau đây cho thấy một số chức năng có thể xuất hiện trong ngữ cảnh thống kê:

from scipy import special
# Gamma functions (generalized factorials) and related functions
x = [1, 5, 10]
print("gamma(x)     =", special.gamma(x))
print("ln|gamma(x)| =", special.gammaln(x))
print("beta(x, 2)   =", special.beta(x, 2))
gamma(x)     = [  1.00000000e+00   2.40000000e+01   3.62880000e+05]
ln|gamma(x)| = [  0.           3.17805383  12.80182748]
beta(x, 2)   = [ 0.5         0.03333333  0.00909091]
# Error function (integral of Gaussian)
# its complement, and its inverse
x = np.array([0, 0.3, 0.7, 1.0])
print("erf(x)  =", special.erf(x))
print("erfc(x) =", special.erfc(x))
print("erfinv(x) =", special.erfinv(x))
erf(x)  = [ 0.          0.32862676  0.67780119  0.84270079]
erfc(x) = [ 1.          0.67137324  0.32219881  0.15729921]
erfinv(x) = [ 0.          0.27246271  0.73286908         inf]

Có rất nhiều, rất nhiều ufuncs khác nhau có sẵn trong cả NumPy và scipy.special. Vì tài liệu của các gói này có sẵn trực tuyến, một công cụ tìm kiếm trên web theo kiểu “hàm gamma python” thường sẽ tìm thấy thông tin liên quan.

Các Tính Năng Nâng Cao của Ufunc

Nhiều người dùng NumPy sử dụng ufuncs mà không học hết các tính năng của chúng.Chúng tôi sẽ trình bày một số tính năng chuyên biệt của ufuncs ở đây.

Chỉ định đầu ra

Đối với các tính toán lớn, có thể trực tiếp chỉ định mảng nơi kết quả tính toán sẽ được lưu trữ.Thay vì tạo ra một mảng tạm thời, điều này có thể được sử dụng để ghi kết quả tính toán trực tiếp vào vị trí bộ nhớ mà bạn muốn.Đối với tất cả các ufunc, điều này có thể được thực hiện bằng cách sử dụng đối số out của hàm:

x = np.arange(5)y = np.empty(5)np.multiply(x, 10, out=y)print(y)
[  0.  10.  20.  30.  40.]

Điều này còn có thể được sử dụng với các xem mảng. Ví dụ, chúng ta có thể viết kết quả của một tính toán vào mỗi phần tử khác của một mảng đã được chỉ định:

y = np.zeros(10)
np.power(2, x, out=y[::2])
print(y)
[  1.   0.   2.   0.   4.   0.   8.   0.  16.   0.]

Nếu chúng ta thay đoạn mã thành y[::2] = 2 ** x, điều này sẽ dẫn đến việc tạo ra một mảng tạm để chứa kết quả của 2 ** x, tiếp theo là một hoạt động thứ hai để sao chép những giá trị đó vào mảng y.

Aggregates

Đối với những hàm ufuncs nhị phân, có một số phép biến đổi đáng chú ý có thể tính trực tiếp từ đối tượng.Ví dụ, nếu chúng ta muốn rút gọn một mảng với một phép toán cụ thể, chúng ta có thể sử dụng phương thức reduce của bất kỳ ufunc nào.Một phương thức reduce lặp đi lặp lại áp dụng một phép toán đã cho vào các phần tử của một mảng cho đến khi chỉ còn lại một kết quả duy nhất.

Ví dụ, gọi reduce trên add ufunc trả về tổng của tất cả các phần tử trong mảng:

x = np.arange(1, 6)
np.add.reduce(x)
15

Tương tự, gọi reduce trên hàm multiply của ufunc sẽ cho kết quả là tích của tất cả các phần tử trong mảng:

np.multiply.reduce(x)
120

Nếu chúng ta muốn lưu trữ tất cả kết quả trung gian của phép tính, chúng ta có thể sử dụng accumulate thay thế:

np.add.accumulate(x)
array([ 1,  3,  6, 10, 15])
np.multiply.accumulate(x)
array([  1,   2,   6,  24, 120])

Chú ý rằng đối với những trường hợp cụ thể này, có các hàm NumPy được dành riêng để tính toán kết quả (np.sum, np.prod, np.cumsum, np.cumprod), chúng ta sẽ khám phá trong Tổng hợp: Min, Max và Mọi Thứ Ở Giữa.

Sản phẩm bên ngoài

Cuối cùng, bất kỳ hàm ufunc nào đều có thể tính toán đầu ra của tất cả các cặp hai đầu vào khác nhau bằng cách sử dụng phương thức outer.Điều này cho phép bạn, chỉ trong một dòng, làm các việc như tạo bảng nhân:

x = np.arange(1, 6)
np.multiply.outer(x, x)
array([[ 1,  2,  3,  4,  5],
       [ 2,  4,  6,  8, 10],
       [ 3,  6,  9, 12, 15],
       [ 4,  8, 12, 16, 20],
       [ 5, 10, 15, 20, 25]])

Các phương thức ufunc.atufunc.reduceat, mà chúng tôi sẽ khám phá trong Fancy Indexing, cũng rất hữu ích.

Một tính năng cực kỳ hữu ích khác của ufuncs là khả năng thực hiện các phép toán giữa các mảng có kích thước và hình dạng khác nhau, một tập hợp các phép toán được gọi là phát sóng.Vấn đề này quan trọng đến mức chúng ta sẽ dành một phần riêng cho nó (xem Tính toán trên Mảng: Phát sóng).

Ufuncs: Tìm hiểu thêm

Thông tin chi tiết về các hàm chung (bao gồm toàn bộ danh sách các hàm có sẵn) có thể được tìm thấy trên trang web tài liệu của NumPySciPy.

Nhớ rằng bạn cũng có thể truy cập thông tin trực tiếp từ bên trong IPython bằng cách nhập gói và sử dụng tính năng hoàn thành bằng tab và trợ giúp (?), như đã mô tả trong Trợ giúp và Tài liệu trong IPython.