Broadcasting terasa nyaman sehingga mudah terlupa satu konsekuensinya: hasil operasi aritmetika selalu berupa array baru rapat sebesar bentuk hasil. Pola (n, 1) berjumpa (1, n) adalah contoh klasiknya — kedua operand ringan, tetapi hasilnya matriks n × n. Bila yang kita perlukan hanya elemen pada diagonal, sisanya miliaran byte yang menganggur.

Bab ini melatih cara membaca tanda bahaya dan tiga alternatif yang lebih hemat:

  • Menghitung biaya memori sebelum menulis operasi.
  • Mengganti matriks perantara dengan np.einsum.
  • Memakai operasi in-place dan perhitungan per baris.

Menghitung biaya memori sebelum menulis kode #

Tanda bahaya pertama adalah bentuk hasil. Untuk n = 10000, bentuk (n, 1) dan (1, n) diperluas menjadi (10000, 10000) — seratus juta elemen float64. Perhitungannya dapat ditebak dulu tanpa membuat array:

python
import numpy as np

n = 10_000
itemsize = np.dtype(np.float64).itemsize
bentuk_hasil = np.broadcast_shapes((n, 1), (1, n))

print("bentuk hasil broadcast:", bentuk_hasil)
print("jumlah elemen         :", n * n)
print("total memori (byte)   :", n * n * itemsize)
print("total memori (MB)     :", n * n * itemsize / 1_000_000)

Output yang Dihasilkan:

text
bentuk hasil broadcast: (10000, 10000)
jumlah elemen         : 100000000
total memori (byte)   : 800000000
total memori (MB)     : 800.0
  1. Baris 5 (np.broadcast_shapes): bentuk hasil dihitung dari metadata saja — tanpa satu byte pun dialokasikan untuk array hasil.
  2. Baris 7-10: 10000 × 10000 elemen dikalikan 8 byte per elemen menghasilkan tepat 800000000 byte, atau 800 MB, untuk satu operasi saja.

Angka tersebut menjelaskan mengapa operasi seperti ini tiba-tiba memenuhi memori mesin: kedua operand hanya memakan puluhan kilobyte, tetapi hasil aritmetikanya ratusan megabyte.

Alternatif einsum untuk kebutuhan diagonal #

Bila dari matriks (n, n) itu yang diperlukan hanya pasangan elemen seposisi — elemen (i, i) — maka seluruh kolom luar tidak pernah dipakai. np.einsum menghitung hanya pasangan itu:

python
import numpy as np

a = np.array([1., 2., 3., 4.])
b = np.array([5., 6., 7., 8.])

matriks = a[:, np.newaxis] * b[np.newaxis, :]
diagonal = np.einsum("i,i->i", a, b)

print(matriks.shape)
print(diagonal)
print(np.array_equal(diagonal, np.diag(matriks)))

Output yang Dihasilkan:

text
(4, 4)
[ 5. 12. 21. 32.]
True
  1. Baris 5 (matriks): pola (4, 1) * (1, 4) menghasilkan matriks (4, 4) penuh — pada n = 10000 inilah 800 MB.
  2. Baris 6 (np.einsum("i,i->i")): notasi i,i->i menyatakan “kalikan elemen seposisi saja”, menghasilkan array (n,) — sekitar 80 KB pada n = 10000, sepuluh ribu kali lebih kecil.
  3. Baris 8 (np.array_equal): True membuktikan hasil einsum identik dengan diagonal matriks penuh.

Mengubah operasi menjadi in-place #

Sumber kedua pemborosan adalah pola a = a * 2, yang membuat array hasil baru sementara array lama menunggu dibersihkan. Operator gabungan seperti *= menulis ulang buffer yang sama:

python
import numpy as np

x = np.arange(1_000_001, dtype=np.float64)
id_awal = id(x)
x = x * 2
print("a = a * 2 membuat array baru :", id(x) != id_awal)

y = np.arange(1_000_001, dtype=np.float64)
id_awal = id(y)
y *= 2
print("a *= 2 mengubah di tempat    :", id(y) == id_awal)

Output yang Dihasilkan:

text
a = a * 2 membuat array baru : True
a *= 2 mengubah di tempat    : True
  1. Baris 4-6 (x = x * 2): id(x) berubah — pernyataan tersebut mengalokasikan array baru berukuran 8 MB dan mengganti ikatan nama x.
  2. Baris 8-10 (y *= 2): id(y) tetap — operasi in-place menulis ulang buffer lama tanpa alokasi baru.
  3. Baris 7 dan 11: perbandingan id sebelum-sesudah menjadi bukti perilaku kedua pola.

Menghitung per baris bila hasil akhirnya kecil #

Sumber ketiga adalah matriks perantara besar yang tujuannya hanya menghasilkan array kecil. Total penjualan setelah potongan per cabang berbentuk (n,) — matriks (n, m) tidak perlu terbentuk utuh:

python
import numpy as np

penjualan = np.array([[10., 12., 9.],
                      [8., 11., 13.],
                      [15., 7., 6.]])
potongan = np.array([1., 2., 0.5])

total_penuh = (penjualan - potongan).sum(axis=1)

total_baris = np.empty(penjualan.shape[0])
for i in range(penjualan.shape[0]):
    total_baris[i] = (penjualan[i] - potongan).sum()

print(total_penuh)
print(total_baris)
print("hasil sama:", np.array_equal(total_penuh, total_baris))

Output yang Dihasilkan:

text
[27.5 28.5 24.5]
[27.5 28.5 24.5]
hasil sama: True
  1. Baris 7 (total_penuh): operasi broadcasting membentuk matriks perantara (3, 3) sebelum diratakan per baris.
  2. Baris 9-11 (loop per baris): tiap baris diproses satu per satu — perantara hanya berukuran (m,), dan hasil akhirnya identik.
  3. Baris 13 (np.array_equal): True menegaskan kedua jalur menghasilkan angka yang sama.

Jalan per baris memang lebih lambat untuk data kecil — pada bab Loop Python vs Vectorization kita melihat biayanya. Keputusan yang seimbang: biarkan broadcasting bekerja bila hasilnya muat di memori, dan beralih ke per baris (atau per potongan) bila perantara membengkak.

PolaPerantara yang terbentukAlternatif hemat
(n, 1) * (1, n) padahal hanya diagonalmatriks n × nnp.einsum("i,i->i")
a = a * 2 pada array besarsalinan baru sebesar ain-place: a *= 2 atau out=a
matriks (n, m) demi hasil (n,)matriks (n, m)loop per baris / per potongan