ai-coding-mines목차GitHub

휴리스틱 점수판에서는 조회 실패가 최저점이 된다 — 실패가 두 번 벌을 받는다

Python · DB

증상

페이지를 읽어 신뢰도를 점수로 매기는 판정기를 만들었다. 안전 신호는 +1, 위험 신호는 -2. 돌려보니 오래 거래해 온 멀쩡한 상대 셋이 나란히 -4점, 즉 최고 위험으로 나왔다.

원인

레이트리밋에 걸려 페이지를 아예 못 읽었다. 그런데 점수가 0이 아니라 음수 바닥으로 갔다. 이유는 규칙의 비대칭이다.

조회 실패 시
가점 규칙 (이메일 주소가 있다 +1)확인 못 함 → 0점, 중립
감점 규칙 (이메일이 없다 -2)"없다"가 성립해 발화감점

★★★ 가점은 존재를 근거로 하고, 감점은 부재를 근거로 한다. 그래서 한 번의 조회 실패가 가점을 잃고 동시에 감점을 발화시킨다. 이메일을 확인하지 못했다는 사실 하나로 +1을 잃고 -2를 먹어 3점이 움직인다. 신호가 여럿이면 배수로 벌어진다.

★★★ 중립이어야 할 사건이 유죄로 작동한다. 그리고 그 결과는 가장 그럴듯한 형태로 나온다 — 최고 위험 판정은 "판정기가 일을 했다"처럼 보이지 "읽지 못했다"처럼 보이지 않는다.

해법

fetched 플래그로 "확인 못 함"과 "없음"을 구분한다.

fetched=False면 감점 규칙을 아예 평가하지 않는다. 점수를 0으로 깎는 것도 아니고 중립값을 주는 것도 아니다 — 판정 자체를 유보하고 판정 불가라는 네 번째 결과를 만든다. 안전/주의/위험 세 가지만 있으면 실패는 반드시 그 셋 중 하나로 새어 들어간다.

if not fetched:
    return Verdict.UNKNOWN      # 0점이 아니다. 등급이 아니다.

★ 그리고 원인 자체를 없애라. 이 경우엔 요청 간격을 두는 것이었다. 판정기를 고치는 것과 수집기를 고치는 것은 별개이고, 둘 다 해야 한다.

★★★ 이건 세 번째 반복이었다

같은 컬렉션에 이미 두 건이 있다 — 조회 실패를 "품절"로 읽은 것, 검색 차단을 "결과 0건"으로 읽은 것. 여기서는 차단을 "위험"으로 읽었다.

★★★ 우연이 아니다. 판정기는 대개 "정상의 증거"를 찾는 구조다. 증거를 못 얻으면 자동으로 부정 판정이 나온다. 판정기를 새로 쓸 때마다 물어야 할 질문은 하나다 — "입력을 못 읽었을 때 이 함수는 무엇을 반환하는가." 대부분의 판정기가 그 경로를 정의하지 않고, 정의하지 않으면 가장 나쁜 등급으로 떨어진다.

검증 방법

★★ 네트워크를 끊고 판정기를 돌려라. 전 건이 판정 불가로 나와야 정상이다. 하나라도 등급이 붙어 나오면 그 규칙이 부재를 근거로 감점하고 있는 것이다. 어서션 한 줄로도 잡힌다.

assert all(v is Verdict.UNKNOWN for v in run_all(offline=True))

★ 덤으로 오탐의 방향도 확인하라. 규모를 위험 신호로 쓰는 규칙(거래처 종수가 너무 많다)은 정상적으로 큰 상대를 전부 위험으로 만든다. 정상 분포의 양 끝이 규칙에 어떻게 걸리는지 봐야 한다.

★★★ 그리고 신호 자체가 신호가 아닐 수 있다 — 형식 검증과 귀속 검증은 다르다

이메일 주소가 있으면 +1을 정규식으로 구현했더니 이런 것들이 가점을 받고 있었다.

뽑힌 주소실체
back-in-stock@notifyboost.net서드파티 앱이 페이지에 심어놓은 주소
support@storefront.com플랫폼 기본 템플릿 값
example@mail.com예시 주소

★★★ "페이지에 이메일이 있다"와 "그 업체의 이메일이 있다"는 다른 명제다. 정규식은 형식만 본다. 발견한 값이 대상에 귀속되는지는 별도로 확인해야 한다 — 여기서는 도메인이 일치하거나 브랜드명을 포함할 것.

★★★ 더 나쁜 건 오탐의 방향이다. 저 셋은 의심스러운 대상일수록 더 잘 나타난다. 템플릿을 그대로 쓴 쪽일수록 플랫폼 기본값과 예시 주소가 지워지지 않고 남아 있다. 즉 이 규칙은 가장 의심스러운 대상에게 가산점을 준다. 오탐이 무작위가 아니라 역방향이다.

★★ 신호를 설계할 때 "이 신호는 위조하기 쉬운가"를 함께 물어라. 위조나 우연한 혼입이 쉬운 항목은 가점으로 쓰면 안 된다. 감점으로만 쓰거나, 귀속 검증을 붙인 뒤에 가점으로 쓴다.

★★ 임계값은 정상 표본으로 정한다

악성 표본 하나(-7점)에 맞춰 안전선을 3점으로 잡았더니, 오래 거래해 온 정상 상대가 조회에 성공하고도 0점, 즉 "주의"로 나왔다. 차단이 아니라 임계값 자체의 문제였다.

정상 표본의 점수 분포를 먼저 보고 선을 그어라. 위험 사례 하나로 맞춘 임계는 정상을 벤다. 그리고 실패 사례는 대개 한 건뿐이지만 정상 사례는 얼마든지 있다 — 분포를 볼 재료가 많은 쪽으로 맞추는 것이 맞다.

정상 10건을 실측하니 1~7점(중앙 5)이었고 악성 1건은 -6점이었다. 간격 7점. 그런데 임계는 3점간격의 한복판(-2.5)이 아니라 정상 분포 안쪽이다. 그래서 정상 하나가 선 아래에 있고 또 하나가 선에 딱 걸려 있었다.

★★★ 그래도 "간격이 7점이니 안정적"이라고 말하면 안 된다 — 악성 표본이 1건뿐이다

정상은 10건인데 악성은 하나다. ★★★ 한 점으로는 분포를 모른다. 두 번째 악성 사례가 -1점으로 나올 수도 있다. "정상 쪽으로 보정하라"는 원칙이 맞다는 것과, 악성 쪽 분포를 안다는 것은 다른 얘기다.

★★ 그러니 모르는 구간에 판정을 강요하지 말고 구간을 하나 더 만들어라. 두 분포 사이의 빈 구역에 경계 — 진행 전 사람이 확인 밴드를 두면, 판정기는 자기가 아는 곳에서만 단정하고 모르는 곳은 넘긴다. 앞의 UNKNOWN과 같은 사고다 — 확신이 없는 곳에 등급을 붙이지 않는다. 악성 표본이 몇 건 쌓인 뒤에 임계를 재산정한다.

★★★ fetched 플래그는 대상 단위가 아니라 신호 단위여야 한다

가점을 빼고 부재에만 약한 감점을 남기는 방향으로 정리하면, 이번엔 그 감점이 fetched의 정확도에 전적으로 의존하게 된다. 그리고 실제 수집은 전부 아니면 전무가 아니다 — 메인 페이지는 받았는데 연락처 페이지만 실패하면 fetched=True인데 그 신호의 근거는 못 본 상태다. → 감점. 처음 고쳤던 그 버그가 부분 실패의 형태로 돌아온다.

★★ 각 신호가 "내 근거를 실제로 읽었는가"를 따로 들고 있어야 한다fetched_email, fetched_address처럼. 대상 단위 플래그 하나는 부분 실패를 전부 성공으로 반올림한다.

★★★ 편향을 제거하면 임계도 다시 정해야 한다 — 보정은 분리도가 아니라 척도를 옮긴다

신호 단위로 분리한 뒤 다시 측정한 결과다.

정상 최저악성간격
이전1-67
이후2-57

★★★ 간격은 그대로고 양쪽이 나란히 올라갔다. 부당 감점이 정상과 악성에 균등하게 걸려 있었기 때문이다 — 조회 실패는 상대를 가리지 않는다.

★★ 그러니 오탐 제거가 판별력을 높인 것은 아니다. 올라간 것은 점수의 의미다. 이제 실제로 읽은 근거만 반영한다. ★★★ 그런데 임계는 옛 척도에 맞춰 놓은 값이라, 계산식을 고치고 임계를 그대로 두면 임계가 조용히 다른 뜻이 된다. 점수 계산에 손을 댔으면 임계는 항상 다시 정한다.

★★★ 최소값에 임계를 맞추지 마라

재조정 후 안전선을 2로 잡았더니, 정상 표본의 최저값이 정확히 2였다. 직전 라운드에서 최저값이 선에 딱 걸렸던 것과 같은 모양이 반복됐다.

★★★ 최소값은 표본이 커질수록 계속 내려가는 통계량이다. 정상 10건의 최저가 2라는 것은 11번째가 1일 수 있다는 뜻이지 정상의 하한이 2라는 뜻이 아니다. 최소값에 선을 붙이면 표본을 늘릴 때마다 선이 따라 내려간다 — 그건 임계가 아니라 관측값이다.

분위수로 잡아라. 정상 하위 10~20%가 어디인지 보고 그 아래에 여유를 둔다.

★ 그리고 오탐의 대가를 밴드로 제한하라. 선 아래로 떨어져도 곧바로 차단이 아니라 한 단계 아래 밴드(가벼운 추가 확인)로 가게 해두면, 임계가 조금 틀려도 손해가 유계다. 밴드가 촘촘할수록 임계 오차에 둔감해진다.

★★★ 실증 — 표본을 10에서 40으로 늘렸더니 최저값이 다시 내려갔다

정상 표본을 40건으로 늘려 분포를 다시 뽑았다.

{1:1, 2:4, 3:5, 4:7, 5:10, 6:10, 7:3}   n=40
최저 1 · p10 2 · p20 3 · 중앙 5 · 최고 7

직전 라운드에서 편향을 고치자 최저값이 1 → 2로 올라갔고, 안전선을 거기 붙였다. 표본을 늘리니 다른 항목이 1점 자리를 채웠다. 예상했던 "11번째가 1일 수 있다"가 그대로 일어났다.

★★★ 최소값은 표본이 커질수록 내려가는 관측값이지 분포의 하한이 아니다. 그리고 40건에서도 1점이 1건뿐이라, 여전히 하한을 안다고 말할 수 없다. 최소값은 표본을 아무리 늘려도 안정되지 않는 통계량이다.

분위수로 잡으면 이 요동이 사라진다. p10 − 1처럼 분위수에서 한 칸 내린 값을 선으로 두면, 표본이 늘어도 선이 크게 안 움직인다.

★★★ 그리고 임계를 맞히려 애쓰기보다, 틀렸을 때의 대가를 작게 만들어라

위 분포에서 권장선은 p10 − 1 = 1인데 실제로는 한 칸 엄격한 2를 썼다. 그 결과 정상의 2.5%(1건)가 선 아래로 떨어진다.

★★ 그래도 괜찮은 이유는 떨어져도 차단이 아니라 "가벼운 추가 확인" 밴드이기 때문이다. 오탐의 비용이 유계다. ★★★ 정확한 임계보다 유계인 대가가 견고하다 — 정확도를 설계로 대신한 자리다.

어느 방향으로 틀릴지를 먼저 정하고 선을 그어라. 여기서는 정상을 한 번 더 확인하는 비용 < 악성을 통과시키는 비용이라 엄격한 쪽으로 틀리도록 잡았다. 방향을 정하지 않으면 임계는 그냥 숫자다.

★★★ 후속 실측 — 표본을 3배로 늘리자 분위수가 그대로였다

전수 점검으로 정상 표본이 40 → 121이 됐다.

n=40n=121
최저11
p1022
중앙55
최고77

★★★ 네 통계량이 전부 그대로다. 분위수 기반 선택이 옳았음이 실증됐다. p10에 걸어둔 선은 표본이 3배가 되어도 움직이지 않았다 — 최소값에 걸었을 때 매 라운드 흔들리던 것과 대조된다.

★★ 최소값이 1로 유지된 것은 해석을 조심한다. 10→40에서는 내려갔고 40→121에서는 안 내려갔다. 1이 실제 하한 근처라는 약한 증거일 뿐, 최소값이 안정적인 통계량이라는 뜻은 아니다. 강한 증거는 중앙값이 3배 표본에서 그대로라는 쪽이다.

⚠️ ★★★ 그런데 "위험 판정 0건"은 검출력의 증거가 아니다

121개는 전부 정상 표본이다. 악성 표본은 여전히 1건이고, 그마저 차단 목록에 올라 분류기를 타지 않는다.

★★★ 정상만 늘리면 "정상을 안 벤다"만 확인된다. "악성을 잡는다"는 확인되지 않는다. 위양성은 121건으로 검증됐고 위음성은 0건으로 검증됐다.

★★ 검출력 표본은 사고가 나야만 생긴다. 생기는 순간 놓치면 영영 없다.차단 목록에 무언가 오를 때마다 그 시점의 점수와 원시 신호를 반드시 저장하라. 사후에는 다시 못 구한다 — 대상이 이미 사라졌거나 바뀌어 있다. 악성 표본이 몇 건 쌓이기 전까지, 임계의 안전 쪽 여유는 검증된 값이 아니라 희망이다.

⚠️ ★★★ 그런데 방금 만든 조기 반환이 그 기록을 불가능하게 만든다

차단을 맨 위에서 조기 반환하도록 고쳤으므로, 차단 목록에 오른 대상은 그 아래 신호가 아예 계산되지 않는다. 정확히 남겨야 할 대상의 관측치가 구조적으로 안 남는다.

★★★ 조기 반환은 판정을 지키는 대신 관측을 버린다. 이 컬렉션의 방어가 다음 함정을 만든다 계열이 또 한 번 나온 것이다 — 우선순위 버그를 고친 그 수가 곧바로 데이터 수집을 막았다.

차단이 맨 위인 것은 여전히 맞다. 바꿔야 하는 것은 기록 시점이다. 차단 목록에 올리기 직전, 차단을 일시 해제한 상태로 전 신호를 한 번 계산해 저장한다. 판정 경로는 조기 반환, 관측 경로는 전 신호 계산 — 둘을 분리한다.

★★★ 그리고 첫 악성 표본이 생기자마자, 완벽해 보이던 것이 깨졌다

점수를 기록해 보니 그 한 건은 -3이었다. 차단 밴드는 -4 미만이다. 유일한 악성 사례가 최고 위험으로 분류되지 않는다.

이력을 되짚으니 그 값은 계속 올라오고 있었다.

시점악성 표본정상 최저간격
최초-718
가짜 가점 신호 제거 후-6
플래그를 신호 단위로 분리 후-527
현재-314

★★★ 위양성을 줄이는 보정이 악성 점수도 같이 올렸다. 부당한 감점을 없애면 억울하게 깎이던 정상뿐 아니라, 마땅히 깎여야 할 악성도 덜 깎인다. 앞에서 "보정은 분리도가 아니라 척도를 옮긴다"고 했는데, 이번에는 척도가 아니라 분리도가 줄었다 — 7에서 4로.

★★★ 이것은 정상 표본만 봐서는 절대 보이지 않는다. 전수 점검에서 "고위험 0건"은 완벽해 보였고, 악성 표본 하나가 생긴 순간 드러났다.

★★ 그러니 회귀 검사를 둬라 — 채점 로직을 손댈 때마다 알려진 악성 표본을 다시 채점하고, 점수가 올라갔으면 실패로 처리한다. 지금은 부작용을 몇 주 뒤에야 알았다. 테스트 스위트에 악성 표본이 한 건이라도 있으면 그 순간 잡혔을 것이다.

그리고 해법은 임계가 아니다. 임계를 내리면 위양성이 늘어난다 — 분리도가 줄어든 원인은 위험 신호 쪽에 있다. 임계 재산정 전에 분리도 추세부터 멈춰야 한다.

★★★ 회귀 기준선에는 숫자가 아니라 모집단을 같이 저장하라

기준선 파일에 분리도 5를 적어두고 바로 문제가 생겼다. 그 5는 대조군 5곳 기준이고, 전체 모집단 기준으로는 4다 — 대조군의 최저가 2, 전체의 최저가 1이기 때문이다.

★★★ 숫자만 저장하면 대조군이 바뀌는 순간 비교가 깨지고, 깨졌다는 사실조차 알 수 없다. 회귀 검사는 "작년 값보다 나빠졌는가"를 묻는데, 작년 값이 무엇을 잰 것인지 모르면 그 질문이 성립하지 않는다. → 기준선에는 값 · 모집단 정의 · 측정 시각을 함께 적는다.

⚠️ ★★★ 그리고 고정 대조군은 과적합을 부른다

대조군 5곳에 전체 분포의 하단이 빠져 있으면 분리도가 과대평가된다. "안 깎였다"를 너무 쉽게 통과시킨다.

★★★ 더 나쁜 건 대조군이 고정이면 결국 그 5곳에 맞춰 튜닝된다는 것이다. 검사가 목표가 되는 순간 검사이기를 멈춘다.

분리도는 대조군의 최저가 아니라 전체 분포의 분위수로 잡아라. 앞에서 본 대로 분위수는 표본이 3배가 돼도 안 움직였다 — 기준선으로 쓰기에 적합한 성질이다. 고정 대조군은 빠른 검사용으로 남기고, 합격/불합격 판정은 전체 분포로 한다.

★★★ 특수 조건에서 얻은 값은 조건을 같이 저장해야 값이 된다

관측 경로를 분리하고 나면 같은 대상에 두 값이 생긴다 — 판정 경로의 차단, 관측 경로의 -3(중간 밴드). 의도된 불일치지만, 저장된 뒤에는 어느 쪽이 "맞는 값"인지 구분이 안 된다. 몇 달 뒤 -3을 보고 "중간 밴드였는데 왜 차단했지"로 읽힌다.

{"score": -3, "band": "borderline",
 "blocks_suspended": true, "observed_at": "...", "purpose": "detection_sample"}

★★ blocks_suspended: true가 없으면 그 숫자는 판정값으로 오인된다.관측값을 판정값과 같은 필드에 쓰지 마라 — 필드가 같으면 표식이 있어도 언젠가 섞인다.

★ 그리고 일반화하면: "판정에 필요 없으니 계산하지 않는다"와 "판정에 필요 없으니 기록하지 않는다"는 다르다. 판정에 불필요한 값이 학습·감사·디버깅에는 필요할 수 있다.

★ 같은 형태는 도처에 있다 — 평소와 다른 설정으로 돌린 벤치마크, 캐시를 비우고 잰 응답시간, 레이트리밋을 풀고 수집한 건수. 조건을 안 적으면 비교 불가능한 값이 정본 자리를 차지한다.

⚠️ ★★ 그리고 필드 이름이 모집단을 배신하면 안 된다

기준선에 "population": "전체 p10(n=121)""normal_low": 2를 같이 적었다. 2는 최저가 아니라 p10이다 — 전체 최저는 1이다.

★★★ 설명 문자열에는 p10이라 적혀 있고, 필드 이름은 low라고 읽힌다. 문자열은 사람이 읽고 필드 이름은 코드가 읽는다. 둘이 어긋나면 코드가 이기고, 조용히 이긴다.normal_p10. 모집단은 설명이 아니라 이름에 박아라.

⚠️ ★★★ "비교 보류"가 검사를 끄는 수단이 된다

기준선에 모집단을 적고 나면 자연스러운 규칙이 따라온다 — "모집단이 다르면 비교를 보류하고 실패로 세지 않는다." 맞는 규칙이다. 앞에서 본 UNKNOWN과 같은 사고이고, 회귀 검사에도 세 번째 값이 생긴다: 통과 / 실패 / 비교 불가.

★★★ 그런데 보류가 조용히 통과로 집계되면 회귀 검사는 무력화된다. 모집단을 한 번 바꾸면 검사가 영원히 "보류"로 남고, 그동안 아무 경보도 뜨지 않는다. 검사를 끄는 가장 쉬운 방법이 "모집단 정의를 살짝 바꾸는 것"이 된다 — 악의 없이도 그렇게 된다.

★★ 보류는 통과와 다른 색으로 보고하고, 연속 N회(예: 3회) 보류면 실패로 승격해 경보하라. ★ 보류할 때는 "무엇을 맞춰야 비교가 재개되는지"를 같이 출력한다 — 그게 없으면 보류는 방치된다.

★★★ 방어 장치가 스스로를 끄는 경로를 만든다. 이 컬렉션의 방어가 다음 함정을 만든다 계열이 또 한 번이고, 이번엔 그 함정이 방어 자체를 겨눈다.

★★ 신호를 추가할 때 통과시킬 다섯 가지

  1. 위조 난이도 — 상대가 마음만 먹으면 만들 수 있는가
  2. 우연 혼입 — 템플릿·서드파티·예시로 저절로 생기는가
  3. 귀속 — 그 값이 대상의 것이라는 근거가 있는가
  4. 오탐 방향 — 틀릴 때 어느 쪽으로 틀리는가
  5. 부재의 의미 — 못 본 것과 없는 것을 구분하는가

1~2가 참이면 가점으로 쓰지 않는다. 3이 없으면 감점으로만 쓴다. ★★ 4가 역방향이면 그 신호는 없느니만 못하다무작위 오탐은 표본을 늘리면 희석되지만, 역방향 오탐은 늘릴수록 나빠진다. 전체 정확도 지표에는 잘 안 잡히고, 정확히 틀리면 안 되는 곳에서 틀린다.