Sepekan yang Kuhabiskan Mengejar Sebuah NaN

Suatu hari training loss-ku berhenti menjadi sebuah angka. Bukan tinggi, bukan tidak stabil — hanya NaN, bendera kecil yang dikibarkan komputer ketika ia diminta melakukan aritmetika yang tidak punya jawaban. Dan kemudian aku butuh hampir sepekan penuh untuk mencari tahu mengapa, karena tempat gejala itu muncul sama sekali tidak dekat dengan tempat masalahnya sebenarnya bersarang.

Kegagalan paling tidak informatif yang ada

Sebuah NaN, dalam arti tertentu, hampir merupakan pesan kesalahan terburuk yang mungkin ada, karena ia nyaris bukan pesan sama sekali. Sebuah crash setidaknya menunjuk ke suatu baris. Sebuah exception membawa tipe dan jejaknya. Sebuah NaN cuma diam-diam meracuni apa pun yang disentuhnya dan terus berjalan: tambahkan ia ke apa pun maka kau dapat NaN, kalikan ia dengan apa pun maka kau dapat NaN, dan dengan agregasi floating-point biasa, rata-ratakan sejuta angka yang sempurna baiknya dengan satu NaN di antaranya maka seluruh rata-ratanya keluar sebagai NaN.

Jadi pada saat ia muncul ke permukaan di loss-ku — keras, final, menghentikan training — ia memberitahuku hampir tidak ada apa-apa tentang di mana nilai buruk yang sebenarnya itu masuk. Gejalanya kira-kira sejauh mungkin dari penyebabnya, dengan rantai aritmetika yang panjang di antara keduanya, yang setiap matarantainya dengan setia menyalurkan racun itu tanpa keluhan.

Men-debug sebuah NaN adalah arkeologi

Dalam kasus ini, tidak ada cara bersih untuk men-debug-nya secara maju. NaN yang bisa kau lihat adalah akhir dari sebuah cerita; nilai buruk yang memulainya terjadi lebih awal, di suatu tempat di hulu, dan sejak itu telah dicuci-bersih melewati lapisan demi lapisan komputasi. Jadi kau bekerja mundur. Kau menambahkan pemeriksaan di setiap tahap — apakah nilainya berhingga di sini? di sini? di sini? — menyusuri pipeline secara terbalik, mencari tempat pertama di mana sebuah angka nyata menjadi angka yang tak-nyata.

Itu pekerjaan yang lambat, tak menawan, dan terasa lebih mirip menggali daripada memprogram: menggali kembali melewati strata transformasi untuk menemukan lapisan persis di mana segala sesuatunya menjadi salah. Sepekan dari hidupku masuk ke penggalian itu.

Sang biang kerok: sebuah transformasi yang diterapkan dua kali

Apa yang akhirnya kugali keluar nyaris menghina kecilnya. Di suatu tempat dalam preprocessing-ku, sebuah transformasi sedang diterapkan ke sebuah nilai — dan kemudian, karena suatu refactor yang kulakukan berpekan-pekan sebelumnya, diterapkan ke nilai itu untuk kedua kalinya. Setiap penerapannya sempurna benar dengan sendirinya. Yang pertama melakukan persis apa yang seharusnya. Yang kedua mengambil nilai yang sudah-ditransformasi itu dan mendorongnya ke suatu tempat di mana matematika tak lagi bisa mengikuti, ke sebuah wilayah di mana operasi itu sederhananya tidak punya hasil yang terdefinisi. Keluarlah sesuatu yang bukan sebuah angka, dan ia pun pergi, ke hilir, untuk muncul ke permukaan sepekan kemudian sebagai loss yang mati.

Bug-nya bukanlah sebuah transformasi yang salah. Ia adalah sebuah transformasi yang benar yang berjalan satu kali terlalu banyak, karena dua bagian berbeda dari kode masing-masing meyakini bahwa merekalah yang bertanggung jawab atasnya.

Mengapa preprocessing justru membiakkan bug semacam ini

Hal semacam ini bukanlah nasib buruk; ia adalah apa yang cenderung dihasilkan oleh pipeline preprocessing ketika kau membiarkannya merembet ke mana-mana. Persiapan data punya kebiasaan terlumur ke seluruh basis kode — sedikit pembentukan-ulang di sini, sedikit penskalaan-ulang di sana, diterapkan di tempat-tempat berbeda oleh fungsi-fungsi berbeda yang ditulis pada waktu-waktu berbeda. Dan begitu transformasi yang sama bisa dicapai lewat dua jalur berbeda, risikonya tumbuh bahwa suatu nilai akan menempuh keduanya dan ditransformasi dua kali.

Tak ada yang salah tentang kedua jalur itu secara terpisah. Itulah yang membuatnya begitu sulit dilihat. Bug-nya tidak bersarang di satu potong kode mana pun; ia bersarang dalam hubungan antara dua potong kode, yang tak satu pun tahu tentang yang lain.

Perbaikannya: satu tempat, satu kali

Reparasinya bukanlah sebuah tambalan ke baris yang buruk itu. Ia bersifat struktural. Aku menarik transformasi-transformasi itu keluar dari tempat-tempat tersebar di mana mereka telah menumpuk dan menaruhnya ke dalam satu tahap terpusat tunggal dengan satu aturan jelas: setiap nilai ditransformasi di sini, persis satu kali, dan tidak di tempat lain mana pun. Setelah itu, tak ada jalur kedua bagi apa pun untuk ditempuh. NaN yang spesifik itu lenyap, tetapi yang lebih penting, ia mengurangi jumlah jalur di mana penerapan yang tidak konsisten bisa terjadi sama sekali — bukan sekadar memperbaiki bug ini, melainkan mengecilkan ruang di mana saudara-saudaranya bisa muncul.

Itulah jenis perbaikan yang layak dikejar. Menambal satu nilai buruk itu akan membuat NaN ini hilang. Memusatkan transformasi membuat banyak versi masa depan dari NaN ini jauh lebih kecil kemungkinannya juga.

Pelajaran yang sesungguhnya: perhatikan jarak antara penyebab dan gejala

Apa yang membuat bug ini mahal bukanlah kesulitannya. Ia adalah jaraknya. Kegagalan itu muncul di satu tempat, penyebabnya duduk di tempat yang sama sekali berbeda, dan tak ada yang secara kasatmata menghubungkan keduanya kecuali sebuah rantai matematika yang panjang dan senyap yang dengan patuh membawa sebuah nilai buruk dari satu ujung ke ujung lainnya.

Pertahanan yang benar-benar membantu melawan jarak semacam itu bukanlah kepintaran. Mereka adalah struktur dan pemeriksaan: pusatkan operasi-operasi berbahaya sehingga hanya ada satu tempat bagi mereka untuk menjadi salah, dan validasi nilai-nilai di batas-batasnya — periksa bahwa sebuah angka berhingga di dekat momen ia diproduksi, bukan sepekan kemudian ketika ia akhirnya meledak. Sebuah nilai buruk yang tertangkap di tempat ia lahir bisa memakan beberapa menit waktumu. Nilai buruk yang sama yang tertangkap di tempat ia meledak bisa memakan sepekan waktumu.

— Tidak ada sinyal, tidak ada imbal hasil, ini bukan nasihat investasi.