5.2 Broadcasting, indexelés és másolás

5.2.1 Cél és konkrét helyzet

Adott egy Northstar-szerű, négy rendelést leíró tömb: delivery_km a rendelésenkénti szállítási távolság, center_km pedig ugyanezen rendelésekhez tartozó, oszlopvektorként érkező bázistávolság. A cél az elemenkénti különbség kiszámítása, vagyis az ii-edik rendelésnél a delivery_kmi−center_kmi\text{delivery\_km}_i - \text{center\_km}_i érték. A megfigyelési egység a rendelés, ezért az elvárt kimeneti alak (4,)(4,). Előzetes becslés: ha a második tömb véletlenül (4,1)(4,1) alakú, a NumPy nem hibát ad, hanem egy (4,4)(4,4)-es mátrixot állít elő. A 4.2-ben tanult, debuggerrel végzett shape-vizsgálat itt beépített alakellenőrzéssé válik; a 5.1-ben definiált shape és axis pedig a tengelyhiba pontos nyelvét adja.

5.2.2 Broadcasting: kompatibilis alakok

A NumPy elemenkénti (elementwise) műveleteket végez az +, -, *, / operátorokkal. Ha a két operandus alakja eltér, a broadcasting (kiterjesztés) dönti el, hogy a kisebb tömb hogyan „ismétlődik” virtuálisan. A szabály:

  1. A két shape-et jobbról balra igazítjuk.
  2. Minden dimenzió kompatibilis, ha a két hossz egyenlő, vagy valamelyik 1.
  3. A hiányzó dimenziók 1 hosszúnak számítanak; az 1 hosszú dimenziók az operáció során a másik dimenzió hosszára bővülnek.
  4. A kimenet alakja a dimenziónkénti hosszak maximuma.

Ez nem matematikai szándékfelismerés, hanem tiszta alak-egyeztetés. A leggyakoribb esetek:

Bal oldali shape Jobb oldali shape Kimeneti shape Értelmezés
(4,) () (4,) skalár + vektor
(4,3) (3,) (4,3) oszloponkénti igazítás
(4,3) (4,1) (4,3) soronkénti igazítás
(4,3) (4,) hiba a 3 és a 4 nem kompatibilis
(4,) (4,1) (4,4) csendes, gyakran nem kívánt külső művelet

A matematikai híd itt a koordinátánkénti művelet és a skaláris szorzat (dot product) különbsége. Az 1D u és v vektorokra:

A NumPy * operátora koordinátánkénti szorzatot jelent, a @ vagy np.dot pedig skaláris szorzatot. A (n,) és (n,1) alakú tömbök kompatibilisek a broadcasting szabályai szerint, de a * ilyenkor nem a páronkénti szorzat, hanem egy n×nn\times n-es külső szorzat-jellegű mátrix. A shape tehát nem csupán technikai részlet: azt mondja meg, milyen matematikai műveletet kapunk.

5.2.3 Boolean mask

Boolean mask alatt olyan logikai tömböt értünk, amely megmondja, mely elemeket válasszuk ki, vagy melyeket módosítsuk. A feltétel ugyanazt a broadcasting szabályt követi; a kiválasztás azonban lapított (flattened), 1D tömböt ad, mert a kijelölt pozíciók halmaza nem feltétlenül téglalap.

import numpy as np

# Megfigyelési egység: rendelés (4 sor).
distance_km = np.array([12.0, 8.0, 20.0, 5.5])
mask = distance_km > 10          # shape (4,), dtype bool
print(mask)                      # [ True False  True False] elméletileg várt
print(distance_km[mask])         # [12. 20.] elméletileg várt

Több feltétel kombinálásához a & és | operátor kell, zárójelekkel, mert a Python and/or kulcsszava nem működik elemekre. A maszk által kijelölt elemeken értéket is adhatunk:

distance_km[mask] = 10.0
print(distance_km)  # [10. 8. 10. 5.5] elméletileg várt

Ez a művelet az eredeti tömböt mutálja, tehát a maszk kiválasztása itt nem hoz létre másolatot a jobb oldal értelemben, hanem az eredeti memóriaterületet írja.

5.2.4 View és copy

A view (nézet) ugyanarra a memóriaterületre mutat, új ndarray objektummal; a copy (másolat) új memóriát foglal. Az alap indexelés (basic indexing) — például a slice, egész index vagy reshape — általában view-t ad, ha a memóriaelrendezés engedi. Az advanced indexing — boolean mask, egész tömbös indexelés — mindig copy-t ad. A különbség akkor válik láthatóvá, ha módosítunk:

a = np.arange(5)          # [0 1 2 3 4]
b = a[1:4]                # view: a memoria egy szelete
b[0] = 99
print(a)                  # [ 0 99  2  3  4] elméletileg várt

c = a[[1, 3]]             # advanced indexing: copy
c[0] = -1
print(a)                  # [ 0 99  2  3  4] elméletileg várt, a változatlan

A np.shares_memory(a, b) ellenőrzi, hogy két tömb osztozik-e memórián. Az a.base attribútum view esetén megadja az eredeti tömböt, copy esetén általában None, de a gyors ellenőrzéshez a np.shares_memory a robusztusabb eszköz. Ha az elemzést meg akarjuk őrizni, explicit copy() kell, mielőtt a származtatott tömböt módosítjuk. A későbbi pandas-fejezetben más másolási szabályokat fogunk tanulni; a NumPy szabályai nem öröklődnek automatikusan a DataFrame-ekre.

5.2.5 Advanced indexing

Az advanced indexing (fancy indexing) két fontos formája: az egész tömbös indexelés és az imént látott boolean mask.

A = np.arange(20).reshape(4, 5)  # 4 sor, 5 oszlop
print(A)
# [[ 0  1  2  3  4]
#  [ 5  6  7  8  9]
#  [10 11 12 13 14]
#  [15 16 17 18 19]]

A[[0, 2], [1, 3]]          # koordináta-párok: A[0,1], A[2,3] -> [1, 13]
A[np.ix_([0, 2], [1, 3])]  # 2x2 blokk: [[1, 3], [11, 13]]

Az első esetben a két index tömb páronként kapcsolódik; az alak a broadcasting működését követi. A második esetben a np.ix_ nyílt mátrixszerű kiválasztást (open mesh) ad. Az A[[0,2]][:, [1,3]] láncolás ugyanazt a blokkot adja, de két copy-t hoz létre; a np.ix_ egyértelműbb, és akkor is helyes, ha a kijelölés tengelyenként eltérő.

5.2.6 Mutáció

A mutáció (mutation) az eredeti tömb tartalmának felülírása, új objektum létrehozása nélkül. A view-n keresztül végzett módosítás az alap tömböt is módosítja; a copy() izolációt ad. A dtype-csapda különösen fontos: az in-place művelet nem változtathatja meg a tároló típust úgy, ahogy az új értékadás teszi.

arr = np.array([1, 2, 3], dtype=np.int64)

# Nem várt viselkedés: az int tárba float nem írható be.
# A pontos hibaüzenet a NumPy verziójától függ; szemléltetően:
# arr += 0.5  # UFuncOutputCastingError vagy hasonló

arr = arr + 0.5   # új float tömb, a régi megmarad
print(arr.dtype)  # float64 elméletileg várt

A maszkkal vagy egész tömbös indexeléssel végzett arr[index] = value mutálja az eredeti tömböt, akkor is, ha a jobb oldali kiválasztás copy-t adna. Ezért ha az eredeti adat kell, a módosítandó munkatömböt másoljuk le.

5.2.7 Teljes minimális munkapélda: a (4,) és (4,1) csapdája

Az alábbi példa a bevezető helyzet hibás és javított változatát mutatja. A kód kimenetei a NumPy szabályaiból levezetett, szemléltető értékek; ezt a kéziratot a generáló környezet nem futtatta, ezért a futást a saját laborkörnyezetben kell ellenőrizni.

import numpy as np

# Megfigyelési egység: rendelés. Szándék: elemenkénti különbség.
delivery_km = np.array([12.0, 8.0, 20.0, 5.5])       # shape (4,)
center_km = np.array([[12.5], [7.0], [18.0], [6.0]]) # shape (4,1)

print(delivery_km.shape)  # (4,)
print(center_km.shape)    # (4,1)

wrong = delivery_km - center_km
print(wrong.shape)        # (4,4) elméletileg várt, nem kívánt
print(wrong)
# [[ -0.5  -4.5   7.5  -7. ]
#  [  5.    1.   13.   -1.5]
#  [ -6.  -10.    2.  -12.5]
#  [  6.    2.   14.   -0.5]]

# Javítás: a (4,1) oszlopvektorból 1D vektor, majd explicit shape-teszt.
center_1d = center_km.ravel()                        # shape (4,)
if center_1d.shape != delivery_km.shape:
    raise ValueError(f"Alak-eltérés: {delivery_km.shape} vs {center_1d.shape}")

diff = delivery_km - center_1d
print(diff.shape)          # (4,) elméletileg várt
print(diff)                # [-0.5  1.   2.  -0.5] elméletileg várt

# Ellenőrzés: kézi kontroll és numerikus invariant.
expected = np.array([-0.5, 1.0, 2.0, -0.5])
assert diff.shape == (4,)
assert np.allclose(diff, expected, atol=1e-12)

A hibás wrong mátrix ii-edik sorának jj-edik oszlopában a delivery_kmj−center_kmi\text{delivery\_km}_j - \text{center\_km}_i különbség áll. A cél ehelyett csak az azonos indexű párok különbsége volt. A ravel() a (4,1)(4,1)-es tárolót az elemek sorrendjében 1D-vé alakítja; mivel az oszlopvektornak egyetlen oszlopa van, a lapítás pontosan a kívánt négy elemet adja. A np.allclose a 5.1-ben bevezetett dtype-hoz igazodó numerikus összehasonlítás; a nullához közeli eltérésekkel itt nem kell számolni, mert a kivonás pontosan reprezentálható.

Ha a műveletet újrahasználható függvénybe zárjuk, a shape-teszt hibaüzenete rögzíti a szerződést:

def subtract_checked(a, b, name_b="b"):
    if b.ndim == 2 and b.shape[1] == 1 and a.ndim == 1:
        raise ValueError(
            f"{name_b} oszlopvektor {b.shape}, de a {a.shape}; "
            "használjon explicit .ravel()-t a koordinátánkénti művelethez."
        )
    out = a - b
    if out.shape != a.shape:
        raise AssertionError(f"Váratlan kimeneti alak: {out.shape}")
    return out

5.2.8 Feladatok

1. Értelmezés. Legyen a = np.array([1, 2, 3]) és b_col = np.array([[10], [20], [30]]). (a) Számolja ki kézzel a * b_col értékét és adja meg az alakját. (b) Milyen matematikai művelet jött létre, és miért nem az elemenkénti szorzat? (c) Milyen alakú eredményt ad a * b_col.ravel(), és mennyi az a @ b_col.ravel() skaláris szorzat?

2. Implementálás. Írjon clip_cap_basic(values, cap) függvényt, amely a values > cap maszk alapján a felső plafont alkalmazza egy új tömbre, az eredeti módosítása nélkül. Tesztelje x = np.array([2.0, 7.0, 12.0, 4.0]) és cap = 10.0 bemenettel. Ellenőrző assert: az output [2.0, 7.0, 10.0, 4.0], az eredeti x pedig [2.0, 7.0, 12.0, 4.0]. Miért fontos a copy(), ha az outputot később módosítani fogjuk?

3. Hibadiagnózis. Egy kolléga oszloponkénti központozást szeretne végezni, de a kódja ezt írja:

X = np.arange(12, dtype=float).reshape(4, 3) + 1
col_mean = X.mean(axis=1, keepdims=True)   # a szándék szerint oszlopátlag?
X_centered = X - col_mean
print(X_centered.shape)                    # miért fut le?

Lefut-e a kód? Milyen alakú col_mean? Melyik átlagot vonja ki? Javítsa úgy, hogy a kimenet oszloponkénti központozás legyen, és assert segítségével ellenőrizze, hogy col_mean.shape == (3,). Mit mutasson X_centered.mean(axis=0)?

4. Transzfer megváltozott feltételre. Adott A = np.array([[1,2,3],[4,5,6],[7,8,9],[10,11,12]]). Készítse el B = A.copy()-t, majd azokban a sorokban, ahol az első oszlop értéke nagyobb, mint 5, állítsa a harmadik oszlopot -1-re. Adja meg az elvárt B mátrixot, és bizonyítsa asseerttel, hogy A változatlan maradt. Milyen indexelési forma történik a maszk és az oszlopindex együttes használatakor?

5.2.9 Megoldások és indoklás

1. (a) A broadcasting a (3,) vektort (1,3) sorrá, a (3,1) oszlopot (3,1) oszloppá egészíti ki; a kimenet alakja (3,3), az (i,j)(i,j) elem aj⋅bia_j \cdot b_i. Kézzel:

[1⋅102⋅103⋅101⋅202⋅203⋅201⋅302⋅303⋅30]=[102030204060306090]. \begin{bmatrix} 1\cdot10 & 2\cdot10 & 3\cdot10\\ 1\cdot20 & 2\cdot20 & 3\cdot20\\ 1\cdot30 & 2\cdot30 & 3\cdot30 \end{bmatrix} = \begin{bmatrix} 10 & 20 & 30\\ 20 & 40 & 60\\ 30 & 60 & 90 \end{bmatrix}.

  1. Ez a két vektor összes páronkénti szorzatát tartalmazó külső szorzat-jellegű művelet; nem az elemenkénti szorzat, mert a (3,)(3,) és (3,1)(3,1) alakok nem páronként igazodnak. (c) a * b_col.ravel() az elemenkénti szorzat [10, 40, 90]; az a @ b_col.ravel() skaláris szorzat 1⋅10+2⋅20+3⋅30=1401\cdot10 + 2\cdot20 + 3\cdot30 = 140.

2. Egy lehetséges megoldás:

def clip_cap_basic(values, cap):
    out = values.copy()       # izoláció a mutációhoz
    out[out > cap] = cap
    return out

x = np.array([2.0, 7.0, 12.0, 4.0])
y = clip_cap_basic(x, 10.0)
assert np.allclose(y, [2.0, 7.0, 10.0, 4.0])
assert np.allclose(x, [2.0, 7.0, 12.0, 4.0])  # az eredeti nem változott

A values.copy() nélkül az out = values csak nézetként ugyanarra a memóriára mutatna; a maszkkal végzett értékadás az eredeti tömböt is módosítaná. Ezért a másolat itt a függvény kimenetének és az inputnak az elválasztását biztosítja.

3. A kód lefut, mert a (4,3) és (4,1) alakok kompatibilisek a broadcasting szerint. A col_mean alakja (4,1), és az axis=1 miatt soronkénti átlag: minden sorból a saját sorátlagot vonja ki. Az oszloponkénti központozáshoz axis=0 kell, keepdims=False mellett:

col_mean = X.mean(axis=0)
assert col_mean.shape == (3,)
X_centered = X - col_mean     # shape (4,3)
assert np.allclose(X_centered.mean(axis=0), 0.0, atol=1e-12)

A X_centered.mean(axis=0) nullához közeli vektor [0, 0, 0], mert a mean(axis=0) a 5.1-ben tanult redukciós tengely mentén törli a sorokat, és pontosan az imént kivont oszlopátlagot adja vissza.

4. A maszk és az oszlopindex együttes használata advanced indexing assignment: a boolean maszk az első tengelyt, az egész oszlopindex a második tengelyt jelöli ki; a bal oldalon ez a kijelölt elemek eredeti memóriáját írja. A várható B:

# B:
# [[ 1  2  3]
#  [ 4  5  6]
#  [ 7  8 -1]
#  [10 11 -1]]
B = A.copy()
mask = A[:, 0] > 5
B[mask, 2] = -1
assert np.array_equal(B, [[1,2,3],[4,5,6],[7,8,-1],[10,11,-1]])
assert np.array_equal(A, [[1,2,3],[4,5,6],[7,8,9],[10,11,12]])

5.2.10 Gyakori tévedés ellenpéldával

A lefutó kód nem bizonyítja, hogy a matematikai művelet a szándékunk szerinti. A (4,)(4,) és (4,1)(4,1) alakú delivery_km - center_km hiba nélkül lefut, de a várt négy rendelés helyett 16 párosításos értéket hoz létre. Ha ezt valaki ellenőrzés nélkül továbbítja egy aggregációba, például .mean()-et számol rá, az átlag már az összes párosítás átlaga lesz, nem a rendelések szerinti különbségeké. A 5.1-ben tanult shape-invariáns itt az első védelmi vonal: rögzítsük előre a kimenet elvárt alakját, és a művelet után assert vagy explicit if formában ellenőrizzük. A broadcasting szándékos külső műveletnél legitim eszköz; a hiba forrása nem a szabály, hanem a hallgatólagos feltételezés, hogy a NumPy kitalálja a matematikai célunkat.

Önellenőrzés: (1) Miért ad a[1:3] view-t, de a[[1,2]] copy-t? (2) Mi a különbség a * b és a @ b között 1D tömböknél? (3) Egy boolean maszk kiválasztás után az eredeti tömb módosul-e, ha a kiválasztott tömböt módosítom? Válaszok: (1) Az alap slice ugyanarra a memóriára mutathat; az advanced integer indexing új tömböt foglal. (2) Az * elemenkénti szorzatot ad, a @ összegzi a páronkénti szorzatok összegét, skalárt eredményezve. (3) Nem, mert a maszk advanced indexing, tehát a kiválasztott tömb copy; de az arr[mask] = value assignment az eredeti tömböt mutálja.

Nézet, másolat, írási mellékhatás. A NumPy alapindexelése rendszerint nézetet, a haladó indexelés rendszerint másolatot ad; a kapott értékek egyezéséből ez nem derül ki. Ellenőrzési példában az eredeti és a kiválasztott tömb módosítása után vizsgáld meg, terjed-e a változás. A np.shares_memory a memóriamegosztás tesztjére használható, de speciális elrendezéseknél az eredményt körültekintően kell értelmezni.

Broadcastingnál mindig írd fel a dimenziókat jobbról balra igazítva; csak egyenlő méretek vagy valamelyik oldalon 1-es dimenzió esetén kompatibilisek. A kompatibilis alak nem jelenti automatikusan, hogy a művelet statisztikailag értelmes.

5.2.11 Rövid összefoglaló

A broadcasting a kisebb alak jobbra igazított, 1 dimenzió mentén történő virtuális ismétlése; a kimenet a dimenziónkénti hosszak maximuma. A (n,) és (n,1) alakok kompatibilisek, de elemenkénti művelettel n×n-es mátrixot adnak, ezért a shape-teszt a szándék szerinti művelet része. A boolean mask és az egész tömbös indexelés advanced indexinget jelent, amely copy-t ad, míg az alap slice view-t; a mutáció izolálásához explicit copy() kell. A következő alfejezetben a lebegőpontos ábrázolás, a hiányzó érték és a stabil számítás épül ezekre az alak- és másolásismeretekre.