Fəsil 02 · 9 dəq oxu

NumPy: array-lərlə hesablama

NumPy: array-lərlə hesablama

Əvvəlki fəsildə Python ekosistemini bir cümlə ilə ümumiləşdirdik: hər şey NumPy array-i üzərində qurulub. İndi həmin array-in özünə baxırıq. Şəkil piksel matrisi, səs amplituda ardıcıllığı, cədvəl isə sütunlardan ibarət ədəd massividir. Data növündən asılı olmayaraq hesablamanın ilk addımı onu array-ə çevirməkdir. Bu fəsil NumPy-ın API-sini sadalamır; məqsəd array-in niyə belə işlədiyini anlamaqdır: niyə Python loop-u yavaş, vectorized əməliyyat sürətlidir, slice niyə kopya deyil, broadcasting fərqli ölçülü array-ləri necə uyğunlaşdırır. Bu anlayışlar növbəti fəsildə pandas-ın da təməlini təşkil edir.

Python ədədi ilə NumPy ədədi arasındakı fərq

Python-da x = 5 yazanda yaddaşda sadəcə 5 saxlanmır. Hər Python obyekti reference count, tip göstəricisi, ölçü və nəhayət dəyərin özünü saxlayan strukturdur. Python-un dynamic typing-inin qiyməti budur: hər ədəd öz tipini özü ilə gəzdirir, ona görə eyni list-də int, string və float yan-yana dura bilir.

Python list-i də bu obyektlərə göstəricilərin massividir. Bir milyon float-lıq list bir milyon ayrı obyekt deməkdir və onlar yaddaşda səpələnmiş halda yaşayır. Hər element üzərində əməliyyat zamanı interpreter göstəricini izləyir, tipi yoxlayır, uyğun funksiyanı tapır, sonra nəticə üçün yeni obyekt yaradır.

NumPy array-i isə fixed-type-dır: bütün elementlər eyni tipdədir və yaddaşda ardıcıl, bitişik blokda saxlanılır. Tip bir dəfə, array səviyyəsində qeyd olunur. Bu iki qərar hər şeyi dəyişir: element üzərində əməliyyat tip yoxlaması olmadan C sürətində gedir, bitişik yaddaş isə CPU səmərəli doldurur.

Python listNumPy array
Element tipihər element öz tipini daşıyırbütün array üçün bir dtype
Yaddaşgöstəricilər + səpələnmiş obyektlərbitişik blok
Çeviklikistənilən obyektyalnız bir tip
Element əməliyyatıinterpreter loop-ukompilyasiya olunmuş C loop-u

Çeviklikdən imtina sürətin qiymətidir və ədədi data üçün bu, demək olar həmişə sərfəli mübadilədir.

Array yaratmaq və dtype

Python list-dən np.array ilə array yaradılır; tiplər fərqlidirsə, NumPy hamısını ən ümumi tipə qaldırır (int və float qarışıqsa, float olur). Amma praktikada array-lər daha çox sıfırdan generasiya olunur:

import numpy as np
rng = np.random.default_rng(0)

np.zeros(5, dtype=int)            # [0, 0, 0, 0, 0]
np.ones((2, 3))                   # 2×3 float matris
np.arange(0, 10, 2)               # [0, 2, 4, 6, 8]
np.linspace(0, 1, 5)              # [0, 0.25, 0.5, 0.75, 1]
rng.normal(size=(3, 3))           # normal paylanmadan 3×3
np.eye(3)                         # vahid matris

dtype array-in hər elementinin yaddaşdakı təsvirini təyin edir: int64, float32, bool, complex128. Seçim yaddaşı birbaşa müəyyən edir (bir milyon float64 8 MB, float32 isə 4 MB tutur) və daşma riskini də: int8 127-dən böyük ədəd saxlaya bilmir. Array-in bu kimi xassələrini dörd atribut göstərir: ndim ölçülərin sayı, shape hər ölçünün uzunluğu, size elementlərin ümumi sayı, dtype elementin tipi.

Indexing, slicing və view-lar

Tək elementə x[0], sondan isə x[-1] ilə çatılır. Çoxölçülü array-də indekslər vergüllə ayrılır: m[2, 0], Python list-dəki m[2][0] yox. Slicing də Python-dakı kimi start:stop:step formasındadır.

Ən vacib fərq budur: NumPy-da slice kopya deyil, view-dur. Yəni alt-array orijinalın yaddaşına baxır və onu dəyişmək orijinalı dəyişir:

m = np.arange(12).reshape(3, 4)
sub = m[:2, :2]
sub[0, 0] = 99
m[0, 0]                           # 99 — orijinal da dəyişdi

Bu, təsadüf deyil, dizayn qərarıdır: böyük dataset-in bir hissəsi ilə işləyəndə hər dəfə kopya yaratmaq həm yaddaşı, həm vaxtı israf edərdi. Müstəqil kopya lazım olanda .copy() çağırın.

reshape elementlərin sayını saxlayaraq shape-i dəyişir və mümkün olanda yenə view qaytarır. np.newaxis isə ölçü əlavə edir: x[np.newaxis, :] 1D array-i sətir vektoruna, x[:, np.newaxis] sütun vektoruna çevirir. Bu, broadcasting bölməsində əsas alət olacaq. Array-ləri birləşdirmək üçün np.concatenate, np.vstack, np.hstack, bölmək üçün isə np.split, np.vsplit, np.hsplit var; hamısı axis arqumenti ilə hansı ölçü boyunca işlədiyini bilir.

Ufunc-lar: loop-u C-yə ötürmək

Bir milyon ədədin hər birinin tərsini Python loop-u ilə hesablamaq saniyələr çəkir. Səbəb bölmə əməliyyatı deyil, hər iterasiyada interpreter-in gördüyü işdir: tipi yoxla, uyğun funksiyanı tap, nəticə obyektini yarat. Bu overhead əməliyyatın özündən yüz dəfələrlə bahadır.

Ufunc (universal function) bu loop-u NumPy-ın kompilyasiya olunmuş kodunun içinə köçürür. 1 / x yazanda NumPy bir dəfə dtype-ı yoxlayır və bütün array üzərində C loop-u işlədir:

x = np.arange(1, 1_000_001)
%timeit [1 / v for v in x]        # ~ 200 ms
%timeit 1 / x                     # ~ 2 ms

Buna vectorization deyilir: Python-da nə hesablanacağını yazırsınız, necə iterasiya ediləcəyini isə NumPy həll edir. Arifmetik operatorlar (+, -, *, /, **, %), np.abs, triqonometrik funksiyalar, np.exp və np.log və onlarla digər funksiya hamısı ufunc-dur. Vərdiş halına gətirin: array üzərində for yazanda dayanıb "bunun ufunc variantı varmı?" deyə soruşun. Cavab demək olar həmişə "bəli"dir.

Ufunc-ların bir neçə az bilinən imkanı var. out= arqumenti nəticəni mövcud array-ə yazır və böyük array-lərdə aralıq kopyadan qaçmağa imkan verir: np.multiply(x, 10, out=y). Hər ufunc-un reduce və accumulate metodu var: np.add.reduce(x) cəmi, np.add.accumulate(x) isə kumulyativ cəmi verir; np.multiply ilə eyni əməliyyatlar hasil və kumulyativ hasil olur. np.multiply.outer(a, b) isə iki array-in bütün cüt hasillərini matris kimi qaytarır, yəni vurma cədvəli bir sətirdir.

Aggregation-lar

Array-i bir ədədə sıxmaq exploration-ın ilk addımıdır: sum, min, max, mean, std, var, median, argmin və argmax (dəyərin özü yox, indeksi). Hamısı Python-un built-in sum və min-dən qat-qat sürətlidir, çünki yenə də loop C-də gedir.

Çoxölçülü array-də axis arqumenti hansı ölçünün yığışdırılacağını göstərir. m.sum(axis=0) sətirləri bir-birinə əlavə edib hər sütun üçün bir ədəd, m.sum(axis=1) isə hər sətir üçün bir ədəd qaytarır:

m = np.arange(6).reshape(2, 3)    # [[0, 1, 2], [3, 4, 5]]
m.sum(axis=0)                     # [3, 5, 7]  — sütun cəmləri
m.sum(axis=1)                     # [3, 12]    — sətir cəmləri
m.max()                           # 5          — bütün array

Real data-da missing dəyərlər NaN ilə işarələnir və NaN hər aggregation-ı "zəhərləyir": np.sum([1, np.nan]) nəticəsi nan-dır. Bunun üçün np.nansum, np.nanmean, np.nanmax kimi NaN-ı nəzərə almayan variantlar var.

Broadcasting

Ufunc-lar eyni shape-li array-lər üzərində element-element işləyir. Bəs x + 5, yəni array ilə scalar? NumPy scalar-ı konseptual olaraq x-in shape-inə "uzadır", amma yaddaşda real kopya yaratmadan. Broadcasting bu uzatmanın ümumi qaydasıdır və üç addımla işləyir:

  1. İki array-in ölçü sayı fərqlidirsə, kiçik olanın shape-inin soluna 1-lər əlavə olunur.
  2. Hər ölçüdə uzunluqlar fərqlidirsə, uzunluğu 1 olan tərəf digərinə uyğun uzadılır.
  3. Hər hansı ölçüdə uzunluqlar fərqlidir və heç biri 1 deyilsə, xəta baş verir.

Nümunə: (3, 1) shape-li sütun ilə (3,) shape-li sətri toplayaq. Qayda 1 ikincini (1, 3) edir, qayda 2 hər ikisini (3, 3)-ə uzadır, nəticə 3×3 matrisdir. Əksinə, (3, 2) ilə (3,) toplana bilmir: qayda 1 ikincini (1, 3) edir, son ölçüdə 2 ilə 3 uyğun gəlmir. Bu halda np.newaxis ilə shape-i əl ilə (3, 1)-ə gətirmək lazımdır.

İki tipik tətbiq:

# 1. Array-i mərkəzləşdirmək: hər sütundan öz ortasını çıxmaq
X = rng.normal(size=(10, 3))
X_centered = X - X.mean(axis=0)   # (10,3) − (3,) → (10,3)

# 2. İki 1D grid-dən 2D funksiya
x = np.linspace(0, 5, 50)[:, np.newaxis]   # (50, 1)
y = np.linspace(0, 5, 40)                  # (40,)
z = np.sin(x) * np.cos(y)                  # (50, 40)

Broadcasting data-nı kopyalamadan fərqli shape-ləri uyğunlaşdırır. Növbəti fəsildə pandas eyni ideyanı bir addım irəli aparır: shape-ə yox, etiketlərə görə uyğunlaşdırır.

Müqayisə, mask və boolean məntiq

Müqayisə operatorları da ufunc-dur: x > 3 eyni shape-li boolean array qaytarır. Bu array-i mask kimi istifadə etmək olur: x[x > 3] yalnız şərti ödəyən elementləri verir. Boolean array-i saymaq üçün np.count_nonzero(mask) və ya mask.sum() (True 1 kimi sayılır), ən azı birinin ödənməsini yoxlamaq üçün np.any, hamısı üçün np.all var; hər ikisi axis qəbul edir.

Şərtləri birləşdirəndə Python-un and, or, not sözləri deyil, bitwise operatorlar &, |, ~ lazımdır. Səbəb odur ki, and bütöv obyektin doğruluğunu soruşur, array üçün isə bu sual mənasızdır (hansı element?). & isə element-element işləyir. Operator prioritetinə görə hər şərt mötərizəyə alınmalıdır:

rain = rng.random(365)
((rain > 0.5) & (rain < 1.0)).sum()   # şərti ödəyən günlərin sayı
rain[(rain == 0) | (rain > 0.9)]      # quraq və ya leysan günlər
np.any(rain > 0.99)

Fancy indexing

Fancy indexing indeks kimi tək ədəd yox, indekslərin array-ini vermək deməkdir: x[[3, 7, 2]] üç elementi həmin sırada qaytarır. Nəticənin shape-i indeks array-inin shape-inə uyğun olur, x-in yox. Çoxölçülü array-də sətir və sütun indeksləri cütləşir və broadcasting qaydalarına tabedir: m[[0, 2], [1, 3]] iki element qaytarır, m[0, 1] və m[2, 3].

Fancy indexing slicing və mask ilə birləşdirilə bilir: m[1:, [2, 0]] birinci sətirdən sonrakı sətirlərin 2-ci və 0-cı sütunlarını verir. Dəyər təyin etmək də işləyir: x[[1, 2]] = 0. Amma təkrarlanan indekslə artırma gözlənilməz nəticə verir:

x = np.zeros(4)
x[[0, 0, 0]] += 1
x                                 # [1, 0, 0, 0] — 3 deyil
np.add.at(x, [0, 0, 0], 1)
x                                 # [4, 0, 0, 0]

x[i] += 1 əslində x[i] = x[i] + 1-dir: x[0] + 1 üç dəfə hesablanır və üç dəfə eyni dəyər yazılır. np.add.at isə hər indeks üçün ayrıca toplayır. Histogram hesablayanda bu fərq nəticəni dəyişir.

Sıralama

np.sort(x) sıralanmış kopya qaytarır, x.sort() isə yerində sıralayır. np.argsort dəyərləri yox, sıralanmış indeksləri verir; bir array-i başqasının sırasına görə düzmək üçün lazımdır: names[np.argsort(ages)]. Çoxölçülü array-də axis hansı ölçü boyunca sıralanacağını göstərir; qeyd edin ki, hər sətir və ya sütun müstəqil sıralanır, sətirlər arasındakı əlaqə itir.

Tam sıralama lazım olmayanda np.partition(x, k) ən kiçik k elementi array-in əvvəlinə, qalanlarını sonrasına qoyur, amma bu iki qrupun daxilini sıralamır. Bu, O(n log n) əvəzinə O(n) çəkir və "ən yaxın 5 qonşu" kimi məsələlərdə məhz bu lazımdır.

Structured array-lər: pandas-a körpü

Hər şey eyni tipli olmayanda NumPy nə təklif edir? Structured array hər elementi bir neçə adlı sahədən ibarət olan array-dir:

people = np.zeros(3, dtype={'names': ('name', 'age', 'weight'),
                            'formats': ('U10', 'i4', 'f8')})
people['name'] = ['Ayan', 'Leyla', 'Murad']
people['age'] = [25, 31, 42]
people[people['age'] > 30]['name']   # ['Leyla', 'Murad']

Record array (np.recarray) eyni şeyi people.age kimi atribut sintaksisi ilə təqdim edir. Bu struktur işləyir, amma etiketli sətirlər, missing data və müxtəlif tipli sütunlar üzərində rahat əməliyyatlar üçün nəzərdə tutulmayıb. Məhz bu boşluğu pandas doldurur və növbəti fəsil ondan başlayır.

Tapşırıqlar

  1. 1 milyon elementli float array yaradın və elementlərinin kvadrat köklərinin cəmini üç yolla hesablayın: Python for loop-u, np.sqrt(x).sum() və np.add.reduce(np.sqrt(x)). %timeit ilə ölçün, sonra %memit ilə yaddaşı müqayisə edin və out= istifadə edərək aralıq array-dən qaçmağa çalışın.
  2. (100, 5) shape-li təsadüfi matris götürün. Yalnız broadcasting istifadə edərək hər sütunu standardize edin (sıfır orta, vahid std) və nəticənin sütun ortalarının həqiqətən sıfır olduğunu np.allclose ilə yoxlayın. Sonra axis=1 ilə eyni şeyi sətirlər üçün edin və harada np.newaxis lazım olduğunu izah edin.
  3. 10 000 təsadüfi 2D nöqtə yaradın və hər nöqtənin ən yaxın 3 qonşusunu loop olmadan tapın: broadcasting ilə bütün cüt məsafələrin matrisini qurun, sonra np.argpartition tətbiq edin. Nəticəni bir neçə nöqtə üçün np.argsort ilə yoxlayın.