NaN 하나를 쫓으며 보낸 한 주

어느 날 내 훈련 손실이 더 이상 숫자가 아니게 되었다. 높은 것도, 불안정한 것도 아니라 — 그냥 NaN, 컴퓨터가 답이 없는 산술을 요구받았을 때 들어 올리는 작은 깃발이었다. 그리고 그 이유를 알아내는 데 거의 한 주가 걸렸다. 증상이 나타난 곳이 문제가 실제로 살고 있던 곳과는 전혀 가깝지 않았기 때문이다.

가장 정보가 없는 실패

NaN은 어떤 의미에서 거의 최악의 오류 메시지에 가깝다. 메시지라고 할 것도 거의 없기 때문이다. 크래시는 적어도 한 줄을 가리킨다. 예외는 타입과 트레이스를 담고 있다. NaN은 그저 자신이 닿는 것은 무엇이든 조용히 오염시키며 계속 나아간다. 무엇에든 더하면 NaN이 나오고, 무엇에든 곱하면 NaN이 나오며, 보통의 부동소수점 집계에서는 멀쩡한 백만 개의 숫자를 그 중 NaN 하나와 함께 평균 내면 평균 전체가 NaN으로 나온다.

그래서 그것이 내 손실에서 — 요란하고, 최종적이고, 훈련을 멈추는 모습으로 — 표면에 떠올랐을 무렵에는, 실제로 나쁜 값이 어디서 들어왔는지에 대해 거의 아무것도 알려주지 않았다. 증상은 원인으로부터 가능한 한 멀리 떨어져 있었고, 그 사이에는 긴 산술의 사슬이 놓여 있었으며, 그 사슬의 모든 고리가 불평 한 마디 없이 충실하게 그 독을 다음으로 넘겨주었다.

NaN을 디버깅하는 것은 고고학이다

이 경우, 그것을 앞으로 가며 디버깅할 깔끔한 방법은 없었다. 당신이 볼 수 있는 NaN은 어떤 이야기의 끝이다. 그것을 시작한 나쁜 값은 더 일찍, 상류 어딘가에서 일어났고, 그 이후로 계층에 계층을 거쳐 계산을 통해 세탁되어 왔다. 그래서 당신은 거꾸로 작업한다. 각 단계에 검사를 추가한다 — 여기서 값이 유한한가? 여기는? 여기는? — 파이프라인을 역방향으로 걸으며, 실재하는 숫자가 비실재의 것이 된 첫 번째 지점을 찾는다.

그것은 느리고, 화려할 것 없는 작업이며, 프로그래밍이라기보다는 발굴에 가깝게 느껴진다. 변환의 지층을 거슬러 파고들며 무언가 잘못된 정확한 층을 찾는 것이다. 내 인생의 한 주가 그 발굴에 들어갔다.

범인: 두 번 적용된 변환

내가 결국 파낸 것은 거의 모욕적일 만큼 작았다. 내 전처리 어딘가에서, 한 변환이 어떤 값에 적용되고 있었고 — 그러고는, 몇 주 전에 내가 했던 리팩터 때문에, 그 값에 두 번째로 적용되고 있었다. 각각의 적용은 그 자체로는 완벽하게 올바랐다. 첫 번째 것은 해야 할 일을 정확히 했다. 두 번째 것은 이미 변환된 값을 가져다가 수학이 더는 따라갈 수 없는 곳으로, 그 연산이 단지 정의된 결과를 갖지 않는 영역으로 밀어 넣었다. 거기서 숫자가 아닌 무언가가 나왔고, 그것은 하류로 떠내려가, 한 주 뒤에 죽은 손실로 표면에 떠올랐다.

그 버그는 잘못된 변환이 아니었다. 그것은 한 번 너무 많이 실행된 올바른 변환이었다. 코드의 서로 다른 두 부분이 각자 자신이 그것을 책임지고 있다고 믿었기 때문이다.

왜 전처리는 정확히 이런 버그를 길러내는가

이런 종류의 일은 불운이 아니다. 그것은 전처리 파이프라인을 제멋대로 퍼지게 내버려 둘 때 그것들이 만들어내는 경향이 있는 결과다. 데이터 준비는 코드베이스 전반에 번져나가는 버릇이 있다 — 여기서 약간 재구성하고, 저기서 약간 재조정하고, 서로 다른 시기에 작성된 서로 다른 함수들에 의해 서로 다른 곳에서 적용된다. 그리고 같은 변환에 두 개의 서로 다른 경로로 도달할 수 있게 되는 순간, 어떤 값이 그 둘을 모두 거쳐 두 번 변환될 위험이 커진다.

어느 경로도 고립시켜 놓고 보면 잘못된 점이 없다. 바로 그 점이 이것을 보기 그토록 어렵게 만든다. 버그는 어느 단일한 코드 조각에도 살지 않는다. 그것은 서로의 존재를 알지 못하는 두 조각 사이의 관계 속에 산다.

해결책: 한 곳에서, 한 번

수리는 그 나쁜 줄에 대한 패치가 아니었다. 그것은 구조적이었다. 나는 변환들을 그것들이 쌓여 있던 흩어진 곳들에서 끄집어내어, 하나의 명확한 규칙을 가진 단일하고 중앙화된 단계로 넣었다. 각 값은 여기서, 정확히 한 번 변환되며, 다른 어디에서도 변환되지 않는다. 그 이후로는, 무엇이든 거쳐 갈 두 번째 경로가 없었다. 특정한 그 NaN은 사라졌지만, 더 중요하게는, 그것이 일관성 없는 적용이 일어날 수 있는 경로의 수 자체를 줄였다 — 단지 이 버그를 고치는 것이 아니라, 그 형제들이 나타날 수 있는 공간을 좁혔다.

그것이 쫓을 가치가 있는 종류의 해결책이다. 그 하나의 나쁜 값을 패치했다면 이 NaN은 사라졌을 것이다. 변환을 중앙화한 것은 이 NaN의 많은 미래 버전들 역시 훨씬 덜 일어나게 만들었다.

진짜 교훈: 원인과 증상 사이의 거리를 살펴라

이 버그를 비싸게 만든 것은 그것의 어려움이 아니었다. 그것은 거리였다. 실패는 한 곳에서 나타났고, 원인은 완전히 다른 곳에 앉아 있었으며, 한쪽 끝에서 다른 쪽 끝으로 나쁜 값을 충실하게 실어 나른 길고 조용한 수학의 사슬 말고는 그 둘을 눈에 보이게 잇는 것이 아무것도 없었다.

그런 종류의 거리에 맞서 실제로 도움이 되는 방어는 영리함이 아니다. 그것은 구조와 검사다. 위험한 연산을 중앙화해서 그것들이 잘못될 수 있는 곳이 단 한 군데뿐이게 하고, 경계에서 값을 검증하라 — 숫자가 한 주 뒤에 마침내 폭발할 때가 아니라, 그것이 생산되는 순간 가까이에서 유한한지 검사하라. 태어난 곳에서 잡힌 나쁜 값은 몇 분을 들게 할 수 있다. 같은 나쁜 값이 폭발하는 곳에서 잡히면 한 주를 들게 할 수 있다.

— 시그널 없음, 수익 없음, 투자 자문 아님.