오염을 넣어도 점수가 안 떨어졌다: DSC 엔진 첫 버전이 결측을 못 센 이유
안녕하세요, 고준서입니다. 4학년 캡스톤은 팀으로 했고, 데이터 품질 점수 엔진(DSC)과 실험 파이프라인은 제가 맡았습니다. 캡스톤의 질문은 “데이터에 매긴 품질 점수가 그 데이터로 학습한 모델의 성능을 예측하는가”였는데, 그 질문에 답하려면 먼저 점수가 오염에 반응은 하는지부터 확인해야 했습니다. 첫 실행에서 그게 안 됐습니다. 결측을 75%까지 넣어도 점수가 99.17에서 97.20으로 2점도 안 떨어졌고, 원인은 지표 셋이 오염 도구가 실제로 만드는 값을 보지 않고 짜여 있던 것이었어요. 같은 날 고친 v2에서는 같은 조건의 점수가 76.34까지 내려갔습니다. 다만 값 오류 오염은 v2에서도 0.04점만 움직여서 문제가 다 풀린 건 아니었어요. 이 글은 그 전후 숫자와 원인 셋, 고친 방법을 그날의 ADR과 실행 로그로 적습니다.
4월 12일 새벽, 60건을 돌렸습니다
실험 구조는 이렇습니다. 공개 데이터셋 3개(Telco 고객 이탈 7,043행, 남독일 신용 1,000행, letter 20,000행)에 DQ4AI라는 오염 도구로 오염 5종(결측, 중복, 값 오류, 표현 불일치, 클래스 불균형)을 10, 25, 50, 75% 네 단계로 넣고, 넣을 때마다 DSC 점수를 잽니다. 3 × 5 × 4 = 60건인데 letter는 범주형 열이 없어 표현 불일치를 건너뛰니 실제로는 56건이었고, 52건이 성공, 4건이 에러였습니다.
에러 4건은 전부 Telco에 값 오류 오염을 넣는 경우였어요. Telco의 TotalCharges 열에 공백 문자열이 11건 섞여 있어서 pandas가 열 전체를 문자열로 읽었고, 오염 도구가 수치형이 아니라고 판단해 가우시안 노이즈 대신 문자열 연결을 해 버린 겁니다. Could not convert string '29.851889.5108.15...' to numeric이 그 흔적입니다. 이건 pd.to_numeric(errors='coerce').fillna(0) 한 줄로 막았습니다(ADR-007).
문제는 성공한 52건이었습니다. Telco 기준으로 점수가 이렇게 나왔어요.
| 오염 유형 | 0% | 10% | 25% | 50% | 75% | 변화폭 |
|---|---|---|---|---|---|---|
| completeness | 99.17 | 99.16 | 99.03 | 98.65 | 97.20 | −1.97 |
| uniqueness | 99.17 | 92.51 | 89.17 | 85.84 | 84.17 | −15.00 |
| feature_accuracy | 99.17 | ERROR | ERROR | ERROR | ERROR | |
| consistent_repr | 99.17 | 92.14 | 92.14 | 92.14 | 92.14 | 고정 |
| class_balance | 99.17 | 99.94 | 99.77 | 99.06 | 97.72 | −1.45 |
표 1. 첫 엔진의 Telco 점수(ADR-008). 결측을 75% 넣었는데 2점이 안 떨어졌습니다.
중복(uniqueness)만 15점이 내려갔고 나머지는 거의 그대로였습니다. 표현 불일치는 강도를 어떻게 바꿔도 92.14로 똑같았고요. 점수가 84에서 99 사이에 다 몰려 있으니 C, D 등급이 하나도 없었고, 등급이 없으면 “등급이 낮을수록 모델 성능이 떨어진다”는 주장을 할 데이터 자체가 없는 상황이었습니다.
원인은 지표 셋에 하나씩 있었습니다
그날 기록한 ADR-008에 원인이 정리돼 있습니다. 이 문서는 Claude Code와 같이 썼고 커밋에 공동 작성자로 남아 있습니다.
첫째, completeness입니다. 오염 도구의 CompletenessPolluter는 결측을 진짜 null로 만들지 않고 placeholder를 넣습니다. 수치형은 -1, 범주형은 "empty"요. 그런데 제 calc_completeness()는 df.isnull().sum()으로 셌습니다. placeholder는 null이 아니니까 하나도 안 잡힌 거죠.
둘째, consistency입니다. ConsistentRepresentationPolluter는 범주값 뒤에 -1, -2 같은 접미사를 붙여 "Yes"를 "Yes-1"로 만듭니다. 제 지표는 re.sub(r'-\d+$', '', x)로 접미사를 떼고 고유값 수를 비교했는데, 그러면 오염이 “있는지”만 보이지 “얼마나”는 안 보입니다. 10%든 75%든 새 표현이 하나라도 생기면 같은 값이 나오는 구조였습니다.
셋째, class_balance입니다. 엔트로피로 재고 있었는데, Telco처럼 이탈 26%, 비이탈 74%인 이진 분류에서는 엔트로피가 0.827에서 시작합니다. 비율을 꽤 왜곡해도 이 값이 크게 안 움직여요. ADR-008에는 “가중치 자체가 0.05로 작으므로 영향 제한적, 다른 지표 수정이 더 우선”이라고 적어 뒀습니다.
셋 다 공통점이 있습니다. 지표를 만들 때 오염 도구가 실제로 어떤 값을 만들어 내는지 안 보고 만들었다는 점이요. “결측은 null이겠지”라고 가정하고 짠 코드가, null을 안 만드는 도구를 만난 겁니다.
같은 날 v2로 고쳤습니다
| 지표 | v1 | v2 |
|---|---|---|
| completeness |
isnull()만 카운트 |
placeholder 값도 결측으로 카운트. compute_dsc()에 placeholder_numerical, placeholder_categorical 파라미터 추가 |
| consistency | 고유값 수 비교 | 접미사가 붙은 행의 비율을 직접 측정 |
| class_balance | 엔트로피 | 최소 클래스 비율을 이상적 균등 비율로 나눔. 2-class 90:10이면 0.2 |
표 2. v2 수정 내용(진행 기록 20260412-02).
completeness는 이렇게 바뀌었습니다. 지금 저장소의 노트북 01에 남아 있는 버전입니다.
def calc_completeness(df, target_col, placeholder_numerical=-1, placeholder_categorical='empty'):
"""결측치 + placeholder 비율. 1=완전, 0=전부 결측."""
feature_df = df.drop(columns=[target_col], errors='ignore')
total_cells = feature_df.shape[0] * feature_df.shape[1]
if total_cells == 0:
return 1.0
missing_count = feature_df.isnull().sum().sum()
if placeholder_numerical is not None:
for col in feature_df.select_dtypes(include=[np.number]).columns:
missing_count += (feature_df[col] == placeholder_numerical).sum()
...
placeholder 값을 인자로 받게 한 건, 오염 도구가 쓰는 값이 설정에 따라 바뀔 수 있어서입니다. 기본값 -1과 'empty'는 DQ4AI의 기본값을 그대로 가져왔습니다.
letter 데이터셋은 v2 엔진으로 그날 바로 다시 쟀고(16건), 나머지 두 데이터셋은 다음 실행에서 v2로 넘어갔습니다. 이날의 커밋은 전부 Claude Opus 4.6과 함께한 작업으로 기록돼 있고, 노트북 수정과 실행 로그 자동 생성 셀이 그쪽 몫이었습니다.
v2로 다시 재니 결측은 21점이 떨어졌습니다
세 데이터셋을 모두 v2로 돌린 실행 로그(results/02_execution_log.md, 실행 시각 4월 12일 13시 54분, 59건, 에러 0건, 111초)에서 Telco 값은 이렇게 바뀌었습니다. 엔진이 달라서 오염 없는 기준 점수가 99.17에서 97.65로 움직였으니, 절댓값보다 하락폭을 봐야 합니다.
| 오염 유형 | 엔진 | 0% | 10% | 25% | 50% | 75% | 하락폭 |
|---|---|---|---|---|---|---|---|
| completeness | v1 | 99.17 | 99.16 | 99.03 | 98.65 | 97.20 | 1.97 |
| completeness | v2 | 97.65 | 95.17 | 91.34 | 84.79 | 76.34 | 21.31 |
| consistent_repr | v1 | 99.17 | 92.14 | 92.14 | 92.14 | 92.14 | 고정 |
| consistent_repr | v2 | 97.65 | 96.25 | 94.14 | 90.62 | 87.11 | 10.54 |
| class_balance | v1 | 99.17 | 99.94 | 99.77 | 99.06 | 97.72 | 1.45 |
| class_balance | v2 | 97.65 | 99.48 | 98.75 | 97.50 | 96.25 | 1.40 |
| uniqueness | v1 | 99.17 | 92.51 | 89.17 | 85.84 | 84.17 | 15.00 |
| uniqueness | v2 | 97.65 | 90.99 | 87.65 | 84.32 | 82.65 | 15.00 |
| feature_accuracy | v1 | 99.17 | ERROR | ERROR | ERROR | ERROR | |
| feature_accuracy | v2 | 97.65 | 97.65 | 97.65 | 97.62 | 97.61 | 0.04 |
표 3. 같은 Telco, 같은 오염에서 v1(ADR-008)과 v2(실행 로그)의 점수.
결측은 1.97점에서 21.31점으로 내려갔고, 92.14에 고정돼 있던 표현 불일치는 96.25에서 87.11까지 강도를 따라 움직이게 됐습니다. 진행 기록에 적어 둔 예상(결측 75%에서 80점 이하)도 76.34로 맞았어요. 값 오류는 에러 4건이 사라졌지만 점수는 97.65에서 97.61로 0.04점 움직였을 뿐입니다. 클래스 불균형은 계산식을 바꿨는데도 총점 하락폭이 1.45점에서 1.40점으로 그대로였는데, ADR-008이 이 지표를 “가중치가 0.05로 작아 영향 제한적”이라며 뒤로 미룬 이유와 맞는 결과입니다. 그 때문인지 지표 값 자체는 이 글에서 열어 보지 않았어요. 중복은 v1에서도 이미 반응하고 있었고 하락폭은 15점 그대로입니다.
이걸로 끝은 아니었습니다
진행 기록에는 “v2 수정 후 completeness 75% 오염이면 80점 이하, 전 등급 분포 생성”이라고 기대를 적어 놨는데, 결측 쪽은 맞았지만 등급 쪽은 아니었습니다. Telco에서 가장 낮은 점수가 76.34로 B였고 C와 D는 나오지 않았어요. 실제로는 v2로 다시 돌린 뒤에도 점수와 모델 성능의 전체 상관계수가 0.08에 머물렀습니다. 결측과 표현 불일치는 이제 보였지만, 값 오류 오염에서 점수가 오히려 올라가는 다른 문제가 남아 있었어요. SouthGermanCredit에서는 값 오류를 넣을수록 97.45가 97.87까지 올랐습니다. 그건 4월 25일 진단에서 잡혔고, 그 이야기는 다음 글에 적었습니다.
함께 읽을 만한 글
-
DSC가 노이즈를 '품질 양호'로 읽었다: 데이터 품질 점수의 사각지대와 signal_integrity
데이터 품질 점수가 노이즈를 오히려 좋게 읽었다. 빼는 열화만 보던 지표 열 개에 노이즈 지표 하나를 더해 상관을 −0.16에서 +0.95로
-
Tabular MC가 네 번 기각된 진짜 이유: 학습이 아니라 추론 코드 한 줄이었다
이틀 동안 네 번 학습시킨 몬테카를로 정책이 전부 게이트에 걸렸는데, 원인은 학습이 아니라 표에 없는 상황에서 random을 두던 한 줄이었다
-
n=200에서 이긴 정책이 n=500에서 떨어졌다: Wilson 신뢰구간 게이트가 걸러낸 16건
200판의 우위는 표본 오차였고 4%를 확인하는 데 2000판이 들었다. 통계 기준이 거른 16건의 수치 그대로
-
노이즈가 심할수록 점수가 올랐다: 자기 IQR로 이상치를 재던 지표
전체 상관이 0.08로 나온 원인을 오염 종류별로 쪼개 찾고, 이상치 지표의 기준을 원본 분포에 고정해 0.42까지 올린 기록
-
RandomForest F1 0.99는 학습이 아니라 누수였다: split-first로 바꾼 기록
오염된 파일을 나누던 파이프라인이 test에 train 복제본을 흘리고 있어서, 먼저 나누고 train만 오염시키는 구조로 바꾼 기록