Implementasi tervektorisasi

Membangun solusi yang sepenuhnya tervektorisasi.

Daftar isi

Topik ini mengerjakan tahap implementasi proyek analisis kisi suhu ruangan, yaitu membangun seluruh perhitungannya dalam bentuk tervektorisasi tahap demi tahap. Tahapan yang dibangun meliputi sintesis data, normalisasi per sensor, ringkasan per jam, dan jarak kesamaan antar sensor, dan setiap perhitungan inti disertai versi perulangan Python sebagai pembanding kebenaran. Kesamaan kedua versi diverifikasi dengan np.allclose pada data yang sama, sebagaimana ditetapkan definisi selesai. Bagian akhir menyatukan semuanya menjadi satu berkas proyek yang dapat dijalankan dari terminal.

Sintesis data kisi suhu#

Data proyek dibangkitkan dari tiga komponen yang masing-masing punya peran. Komponen pertama adalah gelombang harian berbentuk sinus yang menaikkan suhu dari dini hari sampai tengah hari, komponen kedua adalah offset kalibrasi yang berbeda pada setiap sensor, dan komponen ketiga adalah derau acak berdistribusi normal. Ketiganya dijumlahkan lalu dibulatkan satu desimal, sehingga keluarannya mudah dibaca. Kode berikut membangun data (24, 8) dari seed tetap 42.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

print("bentuk data:", suhu.shape)
print("offset kalibrasi per sensor:")
print(np.round(offset, 2))
print("suhu pada jam ke-12 (tengah hari):")
print(suhu[12])
print("rata-rata seluruh kisi:", round(float(suhu.mean()), 2))
text Keluaran
bentuk data: (24, 8)
offset kalibrasi per sensor:
[ 0.82 -0.18  1.08  0.59 -1.22  1.43  0.78  0.86]
suhu pada jam ke-12 (tengah hari):
[25.1 24.7 25.3 25.3 24.5 25.  26.1 26. ]
rata-rata seluruh kisi: 22.5

Bentuk data sesuai peta rencana, dan offset kalibrasinya terlihat berbeda jauh antar sensor, misalnya -1.22 pada sensor 4 berbanding 1.43 pada sensor 5. Perbedaan inilah alasan normalisasi dilakukan per sensor, karena tanpa normalisasi jarak antar sensor lebih banyak mengukur selisih kalibrasi daripada pola suhunya. Suhu tengah hari pada jam 12 berkisar 24.5 sampai 26.1 derajat Celsius, sesuai basis gelombang harian yang puncaknya 25.0.

Normalisasi per sensor dengan keepdims#

Normalisasi per sensor mengurangi setiap kolom dengan rata-rata kolomnya, lalu membaginya dengan simpangan baku kolom yang sama, sehingga setiap sensor memiliki profil dengan skala yang sebanding. Hasilnya disebut z-skor (z-score), yaitu selisih suatu nilai dari rata-rata kolomnya yang dinyatakan dalam satuan simpangan baku. Selisih itu bernilai negatif apabila suhu berada di bawah rata-rata sensornya dan positif apabila berada di atasnya. Notasi xjsx_{js} menyatakan suhu pada jam ke-j untuk sensor ke-s, sedangkan xˉs\bar{x}_s dan sss_s menyatakan rata-rata dan simpangan baku sensor ke-s.

zjs=xjs−xˉsss z_{js} = \frac{x_{js} - \bar{x}_s}{s_s}

Agregasi dilakukan pada axis=0 karena sensor tersusun sebagai kolom, dan keepdims=True menjaga hasilnya tetap berbentuk (1, 8) sehingga siap disiarkan melawan data (24, 8).

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

rata = suhu.mean(axis=0, keepdims=True)
simpangan = suhu.std(axis=0, keepdims=True)
z = (suhu - rata) / simpangan

print("bentuk rata-rata:", rata.shape, "| bentuk z:", z.shape)
print("z tiga jam pertama:")
print(np.round(z[:3], 2))
print("rata-rata z per sensor mendekati nol :", np.allclose(z.mean(axis=0), 0))
print("simpangan z per sensor mendekati satu:", np.allclose(z.std(axis=0), 1))
text Keluaran
bentuk rata-rata: (1, 8) | bentuk z: (24, 8)
z tiga jam pertama:
[[-1.44 -1.75 -1.09 -1.14 -1.32 -0.97 -1.26 -1.67]
 [-1.24 -1.7  -1.05 -1.36 -1.37 -1.56 -0.92 -1.38]
 [-1.39 -1.37 -1.05 -1.09 -1.01 -1.01 -0.44 -1.33]]
rata-rata z per sensor mendekati nol : True
simpangan z per sensor mendekati satu: True

Dua pemeriksaan terakhir mengembalikan True, sehingga syarat definisi selesai untuk tahap ini terpenuhi. Nilai z pada dini hari negatif seluruhnya, misalnya -1.44 pada sensor 0, karena suhu dini hari berada di bawah rata-rata harian sensornya. Sebagai pembanding kebenaran, versi perulangan berikut menghitung hasil yang sama dengan operasi skalar, terdiri atas tiga putaran per sensor yang masing-masing menjumlahkan, menghitung simpangan, lalu menormalisasi. Pembaginya sengaja memakai banyaknya jam sesuai perilaku bawaan std, sehingga keduanya benar-benar setara.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

z = (suhu - suhu.mean(axis=0, keepdims=True)) / suhu.std(axis=0, keepdims=True)

z_loop = np.zeros((JAM, SENSOR))
for s in range(SENSOR):
    total = 0.0
    for j in range(JAM):
        total += suhu[j, s]
    rata_s = total / JAM
    kuadrat = 0.0
    for j in range(JAM):
        kuadrat += (suhu[j, s] - rata_s) ** 2
    simpangan_s = (kuadrat / JAM) ** 0.5
    for j in range(JAM):
        z_loop[j, s] = (suhu[j, s] - rata_s) / simpangan_s

print("allclose z perulangan vs tervektorisasi:", np.allclose(z_loop, z))
text Keluaran
allclose z perulangan vs tervektorisasi: True

Ringkasan per jam dengan axis#

Peringkasan per jam mereduksi sumbu sensor, sehingga axis=1 yang dipakai dan hasilnya berbentuk (24,), yaitu satu baris ringkasan untuk setiap jam. Tiga agregasi dihitung sekaligus, yaitu rata-rata, terendah, dan tertinggi, sehingga setiap jam dapat dibaca sebagai rentang suhu ruangannya.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

rata_jam = suhu.mean(axis=1)
terendah = suhu.min(axis=1)
tertinggi = suhu.max(axis=1)

print("bentuk ringkasan:", rata_jam.shape)
print("jam  rata  terendah  tertinggi")
for j in range(5):
    print(f"{j:3d}  {rata_jam[j]:5.2f}  {terendah[j]:8.2f}  {tertinggi[j]:9.2f}")

rata_loop = np.zeros(JAM)
for j in range(JAM):
    total = 0.0
    for s in range(SENSOR):
        total += suhu[j, s]
    rata_loop[j] = total / SENSOR

print("allclose ringkasan perulangan vs tervektorisasi:", np.allclose(rata_loop, rata_jam))
text Keluaran
bentuk ringkasan: (24,)
jam  rata  terendah  tertinggi
  0  19.68     17.80      21.30
  1  19.68     17.70      20.90
  2  20.19     18.50      21.90
  3  20.32     18.60      21.40
  4  21.25     19.40      22.10
allclose ringkasan perulangan vs tervektorisasi: True

Lima jam pertama memperlihatkan suhu rata-rata yang naik dari 19.68 menuju 21.25, sesuai gelombang harian yang masih menanjak pada dini hari menuju pagi. Selisih antara suhu terendah dan tertinggi pada setiap jam sekitar tiga derajat, yang berasal dari perbedaan offset dan derau antar sensor. Versi perulangan pada blok yang sama menghitung rata-rata per jam dengan penjumlahan skalar, dan pemeriksaan np.allclose mengembalikan True.

Jarak antar sensor dengan broadcasting#

Jarak kesamaan antar sensor dihitung pada profil z-skor, sehingga yang dibandingkan adalah bentuk pola suhunya, bukan kalibrasinya. Setiap sensor dipandang sebagai vektor berisi 24 nilai z, dan jaraknya dihitung dengan jarak Euclidean (Euclidean distance), yaitu akar dari jumlah kuadrat selisih komponennya. Notasi d(i,k)d(i, k) menyatakan jarak antara sensor ke-i dan sensor ke-k.

d(i,k)=∑j=124(zji−zjk)2 d(i, k) = \sqrt{\sum_{j=1}^{24} \left(z_{ji} - z_{jk}\right)^2}

Penyusunan semua pasangan dilakukan dengan broadcasting tiga dimensi, yaitu (8, 1, 24) dikurangi (1, 8, 24), sehingga terbentuk array selisih (8, 8, 24) yang direduksi pada sumbu terakhirnya menjadi matriks jarak (8, 8).

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)
z = (suhu - suhu.mean(axis=0, keepdims=True)) / suhu.std(axis=0, keepdims=True)

selisih = z.T[:, np.newaxis, :] - z.T[np.newaxis, :, :]
jarak = np.sqrt((selisih ** 2).sum(axis=-1))
jarak_norm = np.linalg.norm(z.T[:, np.newaxis, :] - z.T[np.newaxis, :, :], axis=-1)

print("bentuk selisih:", selisih.shape, "| bentuk jarak:", jarak.shape)
print(np.round(jarak, 2))
print("simetris :", np.allclose(jarak, jarak.T))
print("diagonal :", np.allclose(np.diag(jarak), 0))
print("versi norma sama:", np.allclose(jarak, jarak_norm))
text Keluaran
bentuk selisih: (8, 8, 24) | bentuk jarak: (8, 8)
[[0.   1.72 2.05 2.28 2.17 2.08 2.9  1.8 ]
 [1.72 0.   2.38 2.15 2.29 2.12 2.76 2.  ]
 [2.05 2.38 0.   1.89 1.97 2.08 2.58 1.88]
 [2.28 2.15 1.89 0.   2.27 2.4  2.99 2.13]
 [2.17 2.29 1.97 2.27 0.   2.25 2.53 1.98]
 [2.08 2.12 2.08 2.4  2.25 0.   2.19 2.17]
 [2.9  2.76 2.58 2.99 2.53 2.19 0.   2.47]
 [1.8  2.   1.88 2.13 1.98 2.17 2.47 0.  ]]
simetris : True
diagonal : True
versi norma sama: True

Versi np.linalg.norm dengan axis=-1 menghasilkan matriks yang sama, dan pemeriksaan pada blok tersebut mengembalikan True. Matriks jarak simetris dengan diagonal nol sesuai definisi jarak, dan kedua sifat itu diperiksa secara eksplisit. Nilai terkecil di luar diagonal adalah 1.72 pada pasangan sensor 0 dan 1, sedangkan yang terbesar adalah 2.99 pada pasangan sensor 3 dan 6, sehingga pasangan pertama paling mirip dan pasangan kedua paling berbeda polanya.

Versi perulangan menghitung setiap sel matriks dengan tiga perulangan bersarang, yaitu dua perulangan untuk pasangan sensor dan satu untuk profil jam.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)
z = (suhu - suhu.mean(axis=0, keepdims=True)) / suhu.std(axis=0, keepdims=True)

selisih = z.T[:, np.newaxis, :] - z.T[np.newaxis, :, :]
jarak = np.sqrt((selisih ** 2).sum(axis=-1))

jarak_loop = np.zeros((SENSOR, SENSOR))
for i in range(SENSOR):
    for k in range(SENSOR):
        total = 0.0
        for j in range(JAM):
            total += (z[j, i] - z[j, k]) ** 2
        jarak_loop[i, k] = total ** 0.5

print("allclose jarak perulangan vs tervektorisasi:", np.allclose(jarak_loop, jarak))
text Keluaran
allclose jarak perulangan vs tervektorisasi: True

Berkas proyek utuh#

Seluruh tahap di atas disatukan dalam satu berkas analisis_suhu.py, yang memuat fungsi bangkitan data, tiga fungsi tervektorisasi, tiga fungsi perulangan pembanding, dan rangkaian verifikasi definisi selesai. Berkas berikut dapat disalin apa adanya ke dalam folder kerja, lalu dijalankan dari terminal.

python analisis_suhu.py
"""Analisis kisi suhu ruangan.

Kisi suhu 24 jam x 8 sensor dibangkitkan dari seed tetap, dinormalisasi per
sensor, diringkas per jam, lalu dihitung jarak kesamaan antar sensornya.
Setiap tahap memiliki versi tervektorisasi dan versi perulangan pembanding.
"""
import numpy as np

JAM, SENSOR = 24, 8


def buat_suhu():
    rng = np.random.default_rng(42)
    offset = rng.uniform(-1.5, 1.5, size=SENSOR)
    derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
    jam = np.arange(JAM)[:, np.newaxis]
    return np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)


def normalisasi(suhu):
    rata = suhu.mean(axis=0, keepdims=True)
    simpangan = suhu.std(axis=0, keepdims=True)
    return (suhu - rata) / simpangan


def ringkasan(suhu):
    return suhu.mean(axis=1), suhu.min(axis=1), suhu.max(axis=1)


def jarak(z):
    selisih = z.T[:, np.newaxis, :] - z.T[np.newaxis, :, :]
    return np.sqrt((selisih ** 2).sum(axis=-1))


def normalisasi_perulangan(suhu):
    z = np.zeros((JAM, SENSOR))
    for s in range(SENSOR):
        total = 0.0
        for j in range(JAM):
            total += suhu[j, s]
        rata_s = total / JAM
        kuadrat = 0.0
        for j in range(JAM):
            kuadrat += (suhu[j, s] - rata_s) ** 2
        simpangan_s = (kuadrat / JAM) ** 0.5
        for j in range(JAM):
            z[j, s] = (suhu[j, s] - rata_s) / simpangan_s
    return z


def ringkasan_perulangan(suhu):
    rata = np.zeros(JAM)
    for j in range(JAM):
        total = 0.0
        for s in range(SENSOR):
            total += suhu[j, s]
        rata[j] = total / SENSOR
    return rata


def jarak_perulangan(z):
    d = np.zeros((SENSOR, SENSOR))
    for i in range(SENSOR):
        for k in range(SENSOR):
            total = 0.0
            for j in range(JAM):
                total += (z[j, i] - z[j, k]) ** 2
            d[i, k] = total ** 0.5
    return d


suhu = buat_suhu()
z = normalisasi(suhu)
rata_jam, terendah, tertinggi = ringkasan(suhu)
d = jarak(z)

print("bentuk : suhu", suhu.shape, "| z", z.shape,
      "| ringkasan", rata_jam.shape, "| jarak", d.shape)
print("z per sensor  : rata 0?", np.allclose(z.mean(axis=0), 0),
      "| simpangan 1?", np.allclose(z.std(axis=0), 1))
print("jarak         : simetris", np.allclose(d, d.T),
      "| diagonal 0", np.allclose(np.diag(d), 0))
print("vs perulangan : normalisasi", np.allclose(z, normalisasi_perulangan(suhu)),
      "| ringkasan", np.allclose(rata_jam, ringkasan_perulangan(suhu)),
      "| jarak", np.allclose(d, jarak_perulangan(z)))

tanpa_diagonal = d.copy()
np.fill_diagonal(tanpa_diagonal, np.inf)
i, k = np.unravel_index(tanpa_diagonal.argmin(), tanpa_diagonal.shape)
print(f"pasangan sensor paling mirip : {i} dan {k} (jarak {tanpa_diagonal[i, k]:.2f})")
i, k = np.unravel_index(d.argmax(), d.shape)
print(f"pasangan sensor paling beda  : {i} dan {k} (jarak {d[i, k]:.2f})")
text Keluaran
bentuk : suhu (24, 8) | z (24, 8) | ringkasan (24,) | jarak (8, 8)
z per sensor  : rata 0? True | simpangan 1? True
jarak         : simetris True | diagonal 0 True
vs perulangan : normalisasi True | ringkasan True | jarak True
pasangan sensor paling mirip : 0 dan 1 (jarak 1.72)
pasangan sensor paling beda  : 3 dan 6 (jarak 2.99)

Berkasnya dijalankan dengan perintah berikut, dan keluarannya identik dengan blok di atas karena isi berkasnya memang sama.

bash
# menjalankan berkas proyek dari folder yang sama
python3 analisis_suhu.py
text Keluaran
bentuk : suhu (24, 8) | z (24, 8) | ringkasan (24,) | jarak (8, 8)
z per sensor  : rata 0? True | simpangan 1? True
jarak         : simetris True | diagonal 0 True
vs perulangan : normalisasi True | ringkasan True | jarak True
pasangan sensor paling mirip : 0 dan 1 (jarak 1.72)
pasangan sensor paling beda  : 3 dan 6 (jarak 2.99)

Baris verifikasi pada keluaran menutup lima syarat pertama definisi selesai sekaligus, yaitu bentuk setiap keluaran, sifat z per sensor, sifat matriks jarak, dan kesamaan ketiga versi perulangannya. Dua baris terakhir memberi jawaban atas pertanyaan isi proyek, yaitu pasangan sensor yang paling mirip dan paling berbeda polanya. Syarat keenam mengenai pengukuran waktu menjadi isi laporan benchmark modul ini.

Kesalahan umum#

Kesalahan yang paling berbahaya pada normalisasi per sensor adalah menukar axis=0 dengan axis=1. Kode dengan sumbu yang salah tetap berjalan tanpa error karena bentuknya kebetulan cocok, tetapi hasilnya ternormalisasi per jam alih-alih per sensor. Keliru semacam ini tidak menimbulkan pesan apa pun, sehingga satu-satunya pertahanan adalah pemeriksaan definisi selesai.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

z_salah = (suhu - suhu.mean(axis=1, keepdims=True)) / suhu.std(axis=1, keepdims=True)
print("rata-rata z_salah per sensor:")
print(np.round(z_salah.mean(axis=0), 2))
print("pemeriksaan rata-rata nol :", np.allclose(z_salah.mean(axis=0), 0))
print("pemeriksaan simpangan satu:", np.allclose(z_salah.std(axis=0), 1))
text Keluaran
rata-rata z_salah per sensor:
[ 0.18 -0.72  0.63  0.2  -1.7   0.91  0.31  0.18]
pemeriksaan rata-rata nol : False
pemeriksaan simpangan satu: False

Rata-rata z_salah per sensor bukan nol dan kedua pemeriksaan mengembalikan False, sehingga kesalahannya tertangkap sebelum hasilnya dipakai. Kesalahan kedua muncul ketika arah agregasinya per jam tanpa keepdims, misalnya ketika setiap sensor ingin diselisihkan terhadap rata-rata ruangan pada jam yang sama. Hasil suhu.mean(axis=1) berbentuk (24,), dan bentuk tersebut tidak dapat disiarkan melawan dimensi terakhir data (24, 8), sehingga NumPy menolaknya dengan error.

python
import numpy as np

JAM, SENSOR = 24, 8
rng = np.random.default_rng(42)
offset = rng.uniform(-1.5, 1.5, size=SENSOR)
derau = rng.normal(0, 0.8, size=(JAM, SENSOR))
jam = np.arange(JAM)[:, np.newaxis]
suhu = np.round(22.0 + 3.0 * np.sin((jam - 6) * np.pi / 12) + offset + derau, 1)

try:
    deviasi = suhu - suhu.mean(axis=1)
except ValueError as e:
    print(f"ValueError: {e}")
print("dengan keepdims, bentuknya:", (suhu - suhu.mean(axis=1, keepdims=True)).shape)
text Keluaran
ValueError: operands could not be broadcast together with shapes (24,8) (24,) 
dengan keepdims, bentuknya: (24, 8)

Perbaikannya adalah menambahkan keepdims=True agar hasil agregasinya berbentuk (24, 1) dan siap disiarkan. Perlu diperhatikan pula bahwa versi perulangan pembanding harus memakai pembagi yang sama dengan perilaku bawaan std, yaitu banyaknya elemen tanpa koreksi ddof, karena pembeda kecil semacam ini membuat pemeriksaan np.allclose gagal meskipun kodenya tampak benar.

Ringkasan#

  • Data proyek (24, 8) dibangkitkan dari gelombang harian, offset kalibrasi per sensor, dan derau normal, semuanya dari seed 42.
  • Normalisasi per sensor memakai axis=0 dengan keepdims=True, ringkasan per jam memakai axis=1, dan jarak antar sensor memakai broadcasting (8, 8, 24) yang direduksi pada sumbu terakhir.
  • Setiap tahap memiliki versi perulangan pembanding, dan kesamaannya diverifikasi np.allclose yang mengembalikan True.
  • Berkas analisis_suhu.py menyatukan seluruh tahap dan menutup lima syarat teknis definisi selesai dalam satu keluaran.

Referensi#