Tuần Lễ Tôi Dành Để Truy Lùng Một NaN

Một ngày nọ, loss huấn luyện của tôi không còn là một con số nữa. Không cao, không bất ổn — chỉ là NaN, lá cờ nhỏ mà máy tính giương lên khi bị yêu cầu thực hiện một phép tính không có lời giải. Và rồi tôi mất gần trọn một tuần để tìm ra lý do, bởi vì nơi triệu chứng xuất hiện chẳng hề gần nơi vấn đề thực sự cư ngụ.

Lỗi vô thông tin nhất trên đời

Theo một cách nào đó, một NaN gần như là thông điệp lỗi tệ nhất có thể có, bởi vì nó hầu như không phải là một thông điệp gì cả. Một cú crash ít ra còn chỉ vào một dòng. Một exception mang theo một kiểu và một dấu vết. Một NaN chỉ lặng lẽ đầu độc bất cứ thứ gì nó chạm vào rồi tiếp tục: cộng nó với bất cứ gì, bạn được NaN; nhân nó với bất cứ gì, bạn được NaN; và với phép tổng hợp dấu phẩy động thông thường, lấy trung bình một triệu con số hoàn toàn tốt với một NaN lẫn trong đó, cả cái trung bình ấy hóa thành NaN.

Thế nên đến lúc nó trồi lên trong loss của tôi — ầm ĩ, dứt điểm, làm dừng cả việc huấn luyện — nó gần như chẳng nói cho tôi biết gì về nơi giá trị xấu thực sự đã len vào. Triệu chứng nằm cách xa nguyên nhân gần như xa hết mức có thể, với một chuỗi phép tính dài chen vào giữa, mà mỗi mắt xích đều trung thành chuyển tiếp chất độc đi mà chẳng phàn nàn một lời.

Gỡ lỗi một NaN là khảo cổ

Trong trường hợp này, không có cách nào sạch sẽ để gỡ lỗi xuôi chiều. NaN mà bạn thấy được là đoạn kết của một câu chuyện; giá trị xấu khởi đầu nó đã xảy ra sớm hơn, đâu đó ở thượng nguồn, và từ đó đến nay đã được tẩy rửa qua hết lớp này đến lớp khác của tính toán. Vậy nên bạn làm ngược lại. Bạn thêm các điểm kiểm tra ở mỗi giai đoạn — giá trị có hữu hạn ở đây không? ở đây? ở đây? — đi ngược dòng đường ống, tìm cho ra nơi đầu tiên mà một con số thực trở thành một con số phi thực.

Đó là công việc chậm chạp, chẳng hào nhoáng, và cảm giác nó ít giống lập trình hơn là giống khai quật: đào ngược trở lại qua các tầng địa chất của các phép biến đổi để tìm chính xác cái lớp nơi mọi thứ trở nên sai. Một tuần đời tôi đã đổ vào cuộc đào bới ấy.

Thủ phạm: một phép biến đổi được áp dụng hai lần

Thứ mà cuối cùng tôi khai quật được nhỏ đến mức gần như sỉ nhục. Ở đâu đó trong khâu tiền xử lý của tôi, một phép biến đổi đang được áp dụng lên một giá trị — và rồi, vì một lần refactor tôi đã thực hiện nhiều tuần trước đó, nó được áp dụng lên giá trị ấy một lần thứ hai. Mỗi lần áp dụng tự nó hoàn toàn đúng. Lần đầu làm đúng y như nó phải làm. Lần thứ hai lấy cái giá trị vốn đã được biến đổi rồi và đẩy nó tới một nơi mà toán học không còn theo nổi, vào một vùng mà phép toán đơn giản là không có kết quả xác định. Thế là chui ra một thứ không phải là con số, và nó lên đường, xuôi dòng, để rồi một tuần sau trồi lên thành một loss chết.

Lỗi không phải là một phép biến đổi sai. Nó là một phép biến đổi đúng chạy thừa ra đúng một lần, bởi vì hai phần khác nhau của mã đều tin rằng mình chịu trách nhiệm cho nó.

Vì sao tiền xử lý sinh ra đúng cái lỗi này

Loại chuyện này không phải là xui rủi; nó là thứ mà các đường ống tiền xử lý có xu hướng tạo ra khi bạn để chúng lan tràn. Việc chuẩn bị dữ liệu có cái thói bị bôi trét khắp một codebase — một chút reshape ở đây, một chút rescale ở kia, được áp dụng ở những nơi khác nhau bởi những hàm khác nhau viết vào những thời điểm khác nhau. Và khoảnh khắc cùng một phép biến đổi có thể bị chạm tới qua hai con đường khác nhau, nguy cơ tăng lên rằng một giá trị nào đó sẽ đi qua cả hai và bị biến đổi hai lần.

Không con đường nào sai khi xét riêng lẻ. Chính điều đó khiến nó khó thấy đến vậy. Lỗi không cư ngụ trong bất kỳ một mẩu mã đơn lẻ nào; nó cư ngụ trong mối quan hệ giữa hai mẩu mã, mà mẩu này chẳng hề biết đến mẩu kia.

Cách sửa: một nơi, một lần

Việc sửa chữa không phải là một bản vá cho dòng mã xấu. Nó mang tính cấu trúc. Tôi rút các phép biến đổi ra khỏi những nơi rải rác mà chúng đã bồi tụ và đặt chúng vào một giai đoạn trung tâm duy nhất với một quy tắc rõ ràng: mỗi giá trị được biến đổi tại đây, đúng một lần, và không ở đâu khác. Sau đó, không còn con đường thứ hai nào cho bất cứ thứ gì đi qua nữa. Cái NaN cụ thể ấy biến mất, nhưng quan trọng hơn, nó làm giảm số con đường nơi việc áp dụng không nhất quán có thể xảy ra ngay từ đầu — không chỉ sửa lỗi này, mà thu hẹp cái không gian nơi những lỗi anh em của nó có thể xuất hiện.

Đó là kiểu sửa chữa đáng để truy lùng. Vá cái giá trị xấu duy nhất kia hẳn đã làm NaN này biến mất. Tập trung hóa phép biến đổi đã làm cho nhiều phiên bản tương lai của NaN này cũng trở nên ít khả năng hơn nhiều.

Bài học thật sự: hãy để ý khoảng cách giữa nguyên nhân và triệu chứng

Điều khiến lỗi này đắt giá không phải là độ khó của nó. Đó là khoảng cách. Sự cố hiện ra ở một nơi, nguyên nhân ngồi ở một nơi hoàn toàn khác, và chẳng có gì kết nối chúng một cách hữu hình ngoài một chuỗi toán học dài, lặng lẽ, cứ tận tụy mang một giá trị xấu từ đầu này sang đầu kia.

Những phòng tuyến thực sự giúp ích chống lại loại khoảng cách ấy không phải là sự khôn khéo. Chúng là cấu trúc và các điểm kiểm tra: tập trung hóa các phép toán nguy hiểm để chỉ có một nơi cho chúng đi sai, và kiểm định giá trị tại các biên — kiểm tra rằng một con số là hữu hạn gần ngay thời điểm nó được tạo ra, chứ không phải một tuần sau khi nó cuối cùng phát nổ. Một giá trị xấu bị bắt ngay nơi nó sinh ra có thể tốn của bạn vài phút. Cùng giá trị xấu ấy bị bắt nơi nó nổ tung có thể tốn của bạn cả một tuần.

— Không tín hiệu, không lợi nhuận, không phải lời khuyên đầu tư.