P7-2.2까지 읽으면 보통 정규화하면 좋아진다는 인상을 받기 쉽습니다. 하지만 실제 프로젝트에서는 그 다음 질문이 더 중요합니다.
좋아진 것은 어떤 샘플인가?
좋아지지 않은 샘플은 왜 남는가?
전처리를 더 하면 되는가, 아니면 학습 데이터 경계가 비어 있는가?
이 차이를 먼저 표로 고정하면 다음과 같습니다.
실패 유형
흔한 신호
다음 행동
전처리 문제
raw는 틀리고 정규화 후 맞음
스케일, 인코딩, 누락값 처리 다시 보기
경계 데이터 부족
raw도 틀리고 정규화 후도 틀림
더 많은 경계 사례 수집, 특징 보강 검토
애매한 경계 사례
설정마다 예측이 서로 엇갈림
현재 특징만으로 충분한지 다시 보기
예를 들어 z-score 정확도 0.833이 가장 높다면, 빠르게는 z-score만 쓰면 된다고 적고 싶어질 수 있습니다. 하지만 더 안전한 다음 판단은 최고 점수 한 줄로 닫는 것이 아니라, stress-01처럼 전처리로 해결된 샘플이 무엇인지, stress-02처럼 정규화 후에도 남는 샘플이 무엇인지, stress-03처럼 스케일 조정 뒤 오히려 흔들리는 샘플이 무엇인지를 먼저 나누는 것입니다. 그렇게 읽어야 점수가 높은 설정과 모든 실패 해석을 해결한 설정을 섞지 않게 됩니다.
flowchart TD
A["문제 장면<br/>scaled와 z-score가 모두 0.75"]
B["빠른 판단<br/>아무 설정이나 쓰고 넘어간다"]
C["해결된 샘플 확인<br/>전처리로 바로잡힌 것은 무엇인가"]
D["남는 샘플 확인<br/>어떤 설정에서도 틀리는 것은 무엇인가"]
E["갈리는 샘플 확인<br/>설정마다 엇갈리는 것은 무엇인가"]
F["더 안전한 판단<br/>점수보다 실패 유형을 먼저 구분"]
A --> B
A --> C --> D --> E --> F
즉, 비교 실험의 목적은 이 설정이 최고다를 선언하는 것이 아니라 실패의 종류를 좁히는 것입니다.
# 구독 이탈 stress test에서 baseline, raw 1-NN, 부분 스케일 조정, z-score 1-NN을 비교해 실패 원인을 진단하는 예제입니다.importcsvimportnumpyasnpfrompathlibimportPathtrain_path=Path("docs/assets/part-07/chapter-02/p7-2-churn-dataset.csv")stress_path=Path("docs/assets/part-07/chapter-02/p7-2-stress-test.csv")train_rows=list(csv.DictReader(train_path.open(encoding="utf-8")))stress_rows=list(csv.DictReader(stress_path.open(encoding="utf-8")))forrowintrain_rows:row["unresolved_tickets"]=int(row["unresolved_tickets"])row["days_since_login"]=int(row["days_since_login"])row["usage_minutes_30d"]=int(row["usage_minutes_30d"])row["label"]=int(row["label"])forrowinstress_rows:row["unresolved_tickets"]=int(row["unresolved_tickets"])row["days_since_login"]=int(row["days_since_login"])row["usage_minutes_30d"]=int(row["usage_minutes_30d"])row["label"]=int(row["label"])train_only=[rowforrowintrain_rowsifrow["split"]=="train"]defto_matrix(rows):returnnp.array([[row["unresolved_tickets"],row["days_since_login"],row["usage_minutes_30d"]]forrowinrows],dtype=float)X_train=to_matrix(train_only)y_train=np.array([row["label"]forrowintrain_only])X_test=to_matrix(stress_rows)y_test=np.array([row["label"]forrowinstress_rows])defpredict_1nn(train_x,train_y,test_x):predictions=[]nearest_ids=[]forxintest_x:distances=np.linalg.norm(train_x-x,axis=1)nearest_index=int(np.argmin(distances))predictions.append(int(train_y[nearest_index]))nearest_ids.append(train_only[nearest_index]["sample_id"])returnnp.array(predictions),nearest_idsbaseline_class=int(np.bincount(y_train).argmax())baseline_pred=np.full(len(y_test),baseline_class,dtype=int)raw_pred,raw_nearest=predict_1nn(X_train,y_train,X_test)X_train_scaled=X_train.copy()X_test_scaled=X_test.copy()X_train_scaled[:,2]=X_train_scaled[:,2]/60.0X_test_scaled[:,2]=X_test_scaled[:,2]/60.0scaled_pred,scaled_nearest=predict_1nn(X_train_scaled,y_train,X_test_scaled)train_mean=X_train.mean(axis=0)train_std=X_train.std(axis=0)ifnp.any(train_std==0):raiseValueError("표준편차가 0인 특징이 있어 z-score 정규화를 할 수 없습니다.")X_train_z=(X_train-train_mean)/train_stdX_test_z=(X_test-train_mean)/train_stdz_pred,z_nearest=predict_1nn(X_train_z,y_train,X_test_z)comparison_rows=[]fori,rowinenumerate(stress_rows):model_errors={"baseline":int(baseline_pred[i]!=y_test[i]),"raw_1nn":int(raw_pred[i]!=y_test[i]),"scaled_1nn":int(scaled_pred[i]!=y_test[i]),"zscore_1nn":int(z_pred[i]!=y_test[i]),}ifmodel_errors["raw_1nn"]andnotmodel_errors["zscore_1nn"]:diagnosis="전처리로 해결됨"elifmodel_errors["zscore_1nn"]:diagnosis="정규화 후에도 남는 경계 사례"eliflen({int(raw_pred[i]),int(scaled_pred[i]),int(z_pred[i])})>1:diagnosis="설정에 따라 갈리는 경계 사례"else:diagnosis="현재 비교 실험에서는 안정적"comparison_rows.append({"샘플":row["sample_id"],"focus":row["focus"],"정답":row["label"],"baseline":int(baseline_pred[i]),"raw_1nn":int(raw_pred[i]),"scaled_1nn":int(scaled_pred[i]),"zscore_1nn":int(z_pred[i]),"raw_nearest":raw_nearest[i],"z_nearest":z_nearest[i],"failure_diagnosis":diagnosis,})summary={"baseline 정확도":round(float((baseline_pred==y_test).mean()),3),"raw 1-NN 정확도":round(float((raw_pred==y_test).mean()),3),"부분 스케일 조정 1-NN 정확도":round(float((scaled_pred==y_test).mean()),3),"z-score 1-NN 정확도":round(float((z_pred==y_test).mean()),3),"전처리로 해결된 샘플":[row["샘플"]forrowincomparison_rowsifrow["failure_diagnosis"]=="전처리로 해결됨"],"정규화 후에도 남는 샘플":[row["샘플"]forrowincomparison_rowsifrow["failure_diagnosis"]=="정규화 후에도 남는 경계 사례"],}representative_ids={"stress-01","stress-02","stress-03","stress-04"}representative_rows=[rowforrowincomparison_rowsifrow["샘플"]inrepresentative_ids]print("비교 실험 요약 =",summary)print("학습 파일 =",str(train_path))print("스트레스 평가 파일 =",str(stress_path))print("대표 샘플별 비교 =")forrowinrepresentative_rows:print(row)
stress-01은 raw 거리에서는 retained 쪽으로 잘못 붙었지만, 사용 시간 스케일을 줄이거나 z-score 정규화를 적용하자 churn으로 바로잡혔다. 반면 stress-02와 stress-03은 설정에 따라 판단이 뒤집히며, 정규화가 항상 더 안전한 해결책은 아니라는 점을 보여 준다. 따라서 다음 반복에서는 전처리 튜닝만 더 하는 대신 경계 구간 고객 사례를 추가 수집하고, 필요하면 결제 실패 횟수 같은 새 특징도 검토하는 편이 적절하다.
# action event 요약에서 원시 tracking 오류, 구간 특징, 기준선 gap flag를 비교해 반복 drift 후보를 찾는 예제입니다.importcsvfrompathlibimportPathsummary_path=Path("docs/assets/part-07/chapter-01/p7-action-unit-summary.csv")rows=list(csv.DictReader(summary_path.open(encoding="utf-8")))forrowinrows:row["event_order"]=int(row["event_order"])row["mid_flow_mean"]=float(row["mid_flow_mean"])row["late_drop_rate"]=float(row["late_drop_rate"])row["tracking_error_mean"]=float(row["tracking_error_mean"])baseline_rows=[rowforrowinrowsifrow["period"]=="baseline"]recent_rows=[rowforrowinrowsifrow["period"]=="recent"]baseline_mid=sum(row["mid_flow_mean"]forrowinbaseline_rows)/len(baseline_rows)baseline_late=sum(row["late_drop_rate"]forrowinbaseline_rows)/len(baseline_rows)comparison=[]forrowinrecent_rows:raw_flag=row["tracking_error_mean"]>0.12segment_flag=row["mid_flow_mean"]<2.10orrow["late_drop_rate"]>0.15baseline_gap_flag=(row["mid_flow_mean"]-baseline_mid<-0.20orrow["late_drop_rate"]-baseline_late>0.05)ifsegment_flagandnotraw_flag:diagnosis="원시 오류만 보면 놓치지만 구간 특징으로 잡힘"elifraw_flagandnotsegment_flag:diagnosis="단발 튐 가능성이 있어 재현 확인 필요"elifsegment_flagandbaseline_gap_flag:diagnosis="반복 drift 후보"else:diagnosis="현재 비교에서는 낮은 우선순위"comparison.append({"event_id":row["event_id"],"raw_tracking_flag":raw_flag,"segment_feature_flag":segment_flag,"baseline_gap_flag":baseline_gap_flag,"diagnosis":diagnosis,})forrowincomparison:print(row)
길이가 다른 동작을 비교할 때는 같은 5초 구간을 비교할지, 전체 동작의 10%, 20%, 30%처럼 진행도 구간을 비교할지 먼저 정해야 합니다. 절대 시간축은 실제 시각 차이를 보존하지만, 동작 길이가 다르면 서로 다른 단계가 한 칸에 섞일 수 있습니다. 진행도축은 초반, 중반, 후반처럼 같은 단계끼리 비교하기 쉽지만, 실제로 몇 초가 걸렸는지는 약해집니다.
비교 축
잘 보이는 것
조심할 점
절대 시간축
몇 초 뒤에 신호가 달라졌는가
짧은 동작과 긴 동작의 단계가 섞일 수 있음
진행도축
같은 진행 단계에서 패턴이 달라졌는가
실제 소요 시간 차이를 놓칠 수 있음
이 선택 실험은 별도 모델을 더하는 일이 아닙니다. 같은 동작 요약을 읽기 전에 지금 질문이 실제 시간 차이인가, 같은 단계 비교인가를 먼저 고르는 연습입니다.