이 글은 데이콘 "구조물 안정성 물리 추론 AI 경진대회" 에 참여하면서
모델을 단계적으로 개선한 과정을 정리한 기록입니다!
전처리 개선 → Dual-View 아키텍처 → K-Fold 앙상블 → Pseudo Labeling 순서로 진행하였으며, 각 단계에서 어떤 문제가 있었고 어떻게 해결했는지를 중심으로 설명해보겠습니다.
1. 대회 개요
이 대회는 구조물의 전면(front) 이미지와 상단(top) 이미지 두 장을 입력으로 받아, 해당 구조물이 안정(stable) 한지 불안정(unstable) 한지를 분류하는 문제입니다.
- 입력:
front.png+top.png(이미지 쌍) - 출력:
unstable_prob,stable_prob(각 클래스 확률값) - 평가지표: LogLoss (낮을수록 좋음)
- 데이터 구성: Train 1,000개 / Dev 100개 / Test (비공개)
LogLoss는 단순히 맞고 틀리는 것이 아니라, 얼마나 확신을 갖고 맞혔는지까지 측정하는 지표입니다. 예측 확률이 실제 정답에서 조금만 벗어나도 손실이 크게 올라가기 때문에, 확률 보정(Calibration)이 매우 중요합니다.
2. 전처리 개선
2.1 기존 방식의 문제점
초기에는 224×224 리사이즈와 기본적인 증강만 적용하였습니다.
실험 과정에서 두 가지 증강이 성능을 크게 떨어뜨리는 것을 확인하였습니다.
문제 1 – RandomResizedCrop
scale=(0.7, 1.0)으로 설정하면 이미지의 최대 30%가 잘려나갈 수 있습니다.
물리적 안정성 판단은 구조물의 전체 형태에서 이루어지기 때문에, 일부를 잘라내는 증강은 모델에게 잘못된 정보를 주게 됩니다.
구조물의 일부만 보고 안정성을 판단하는 것은 사람도 어렵습니다.
모델도 마찬가지입니다. 전체 구조를 항상 입력으로 제공하는 것이 기본 원칙이어야 합니다.
문제 2 – RandomEqualize
히스토그램 균일화 증강을 p=0.2로 추가하였을 때, 일부 fold에서 Val LogLoss가 0.68 수준(랜덤 분류기와 동일)까지 치솟는 현상이 발생하였습니다. Gradient explosion으로 인한 학습 불안정이 원인이었으며, 해당 증강을 제거하자 즉시 정상화되었습니다.
2.2 개선된 전처리 코드
IMAGE_SIZE = 288 # 224 → 288
train_transform = transforms.Compose([
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE),
interpolation=transforms.InterpolationMode.BICUBIC),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomVerticalFlip(p=0.5),
transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2),
transforms.RandomPerspective(distortion_scale=0.2, p=0.3),
transforms.RandomAutocontrast(p=0.2),
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3)], p=0.3),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
- 이미지 크기 288px로 상향: 더 세밀한 구조물 특징을 포착하기 위함
- ColorJitter 추가: 조명 조건 변화를 시뮬레이션
- RandomPerspective 추가: 카메라 각도 변화를 시뮬레이션
이 두 가지를 추가한 이유는 이 대회의 핵심 어려움인 도메인 갭(Domain Gap) 때문입니다.
- Train/Dev 데이터: 고정된 실험실 조명, 정해진 카메라 각도
- Test 데이터: 다양한 조명 조건, 랜덤한 카메라 위치
학습 환경과 테스트 환경이 다르기 때문에, 학습 데이터에서 이러한 변화를 인위적으로 시뮬레이션하여 모델이 더 다양한 조건에 대응할 수 있도록 하였습니다.
3. Dual-View 아키텍처
3.1 기존 방식의 한계
초기 구현에서는 front.png와 top.png를 6채널로 합쳐서 하나의 입력으로 넣었습니다.
[front R, front G, front B, top R, top G, top B] → 6채널 입력
이 방식의 문제는 ImageNet으로 사전학습된 모델이 3채널 RGB 입력을 기대한다는 점입니다.
6채널로 변경하면 첫 번째 Conv 레이어의 가중치를 새로 초기화해야 하므로 사전학습의 효과가 줄어듭니다.
3.2 Dual-View 방식
front.png (3채널) → ConvNeXt Backbone → feature_front
top.png (3채널) → ConvNeXt Backbone → feature_top
↓
concat([feature_front, feature_top])
↓
MLP Classifier → 2클래스
- front와 top을 각각 독립적으로 동일한 backbone에 통과시킵니다.
- 두 특징 벡터를 concat한 뒤 분류기에 전달합니다.
- 3채널 그대로 사용하므로 ImageNet 사전학습을 그대로 활용할 수 있습니다.
class DualViewConvNeXt(nn.Module):
def __init__(self, model_name='convnext_base', num_classes=2):
super().__init__()
self.backbone = timm.create_model(
model_name, pretrained=True, num_classes=0, in_chans=3)
feat_dim = self.backbone.num_features
self.classifier = nn.Sequential(
nn.Linear(feat_dim * 2, 512),
nn.LayerNorm(512),
nn.GELU(),
nn.Dropout(0.3),
nn.Linear(512, 256),
nn.GELU(),
nn.Dropout(0.2),
nn.Linear(256, num_classes),
)
def forward(self, front, top):
feat_f = self.backbone(front)
feat_t = self.backbone(top)
return self.classifier(torch.cat([feat_f, feat_t], dim=1))
3.3 학습 설정
| 항목 | 값 |
|---|---|
| 모델 | ConvNeXt-Base |
| 이미지 크기 | 384px |
| 배치 크기 | 2 (실질 16 – Gradient Accumulation × 8) |
| 학습률 | 3e-5 |
| Optimizer | AdamW |
| Scheduler | OneCycleLR (warmup 10%, cosine annealing) |
| 에폭 | 최대 50, EarlyStopping patience=10 |
Gradient Accumulation을 적용한 이유는 384px 고해상도 이미지가 GPU 메모리를 많이 사용하기 때문입니다. 배치 크기 2로 8번 누적하면 실질적으로 배치 크기 16의 효과를 낼 수 있습니다.
4. K-Fold 교차검증 + 앙상블
4.1 Train+Dev 통합 K-Fold
Train 1,000개만 학습에 사용하는 대신, 정답이 있는 Dev 100개도 통합하여 1,100개로 학습하였습니다.
- 5-Fold 교차검증 → fold마다 220개 검증, 880개 학습
- 검증 데이터는 항상 원본(clean) 데이터로만 구성 → Val LogLoss의 신뢰성 보장
from sklearn.model_selection import StratifiedKFold
all_df = pd.concat([train_df, dev_df]).reset_index(drop=True) # 1100개
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
for fold, (train_idx, val_idx) in enumerate(skf.split(all_df, all_df['label']), 1):
train_df_fold = all_df.iloc[train_idx]
val_df_fold = all_df.iloc[val_idx]
4.2 OOF (Out-Of-Fold) 예측 저장
각 fold 검증 시 logits를 저장해두면, 나중에 Temperature Scaling에 활용할 수 있습니다.
# 학습 완료 후 OOF 저장
oof_logits = np.vstack(oof_logits_list) # shape: (1100, 2)
oof_labels = np.array(oof_labels_list) # shape: (1100,)
np.savez(f'{save_dir}/oof_predictions.npz',
logits=oof_logits, labels=oof_labels)
4.3 Multi-Seed 앙상블
동일한 모델 구조라도 랜덤 시드가 다르면 조금씩 다른 특징을 학습합니다.
여러 시드의 예측을 평균내면 분산이 줄어드는 효과가 있습니다.
| Seed | 지표 | 값 |
|---|---|---|
| 42 | Dev LogLoss | 0.0289 |
| 123 | Dev LogLoss | 0.0266 |
| 777 | OOF LogLoss | 0.0250 |
3개 시드 × 5 fold = 15개 모델 앙상블으로 구성하였습니다.
4.4 OOF 기반 Temperature Scaling
모델의 예측 logits를 온도 T로 나눠주면, 확률 분포를 보정할 수 있습니다.
def find_optimal_temperature(logits, true_labels, eps=1e-15):
def neg_logloss(T):
scaled = logits / T
exp_s = np.exp(scaled - scaled.max(axis=1, keepdims=True))
probs = np.clip(exp_s / exp_s.sum(axis=1, keepdims=True), eps, 1 - eps)
oh = np.zeros((len(true_labels), 2))
oh[np.arange(len(true_labels)), true_labels] = 1
return -np.sum(oh * np.log(probs), axis=1).mean()
result = minimize_scalar(neg_logloss, bounds=(0.8, 1.5), method='bounded')
return result.x
중요한 주의 사항: Dev 데이터로 T를 최적화하면 안 됩니다.
Dev로 T까지 맞추면 Data Leakage가 발생합니다. 처음에 이 실수를 하였을 때 Dev LogLoss가 0.0000이 되는 현상이 나타났으며, 이는 완전한 치팅이었습니다.
해결 방법: T 최적화는 OOF logits 만을 사용합니다.
Dev는 최종 결과 확인용으로만 사용합니다.
4.5 TTA (Test Time Augmentation)
추론 시에도 여러 가지 변환을 적용한 뒤 예측값을 평균냅니다.
tta_transforms = [
원본,
좌우 반전,
상하 반전,
좌우 + 상하 반전,
밝기 +20%,
밝기 -20%,
]
6가지 변환 × 15개 모델 = 90번의 추론 결과를 평균하여 최종 예측을 생성합니다.
v3 리더보드 제출 결과: LogLoss 0.0601
5. Pseudo Labeling
5.1 개념
Pseudo Labeling은 학습된 모델이 Test 데이터를 예측하였을 때, 확신도가 매우 높은 샘플에 대해 가짜 정답(Pseudo Label)을 부여하고 학습 데이터에 추가하는 기법입니다.
현재 모델 → Test 예측 → 확신도 > 98% 샘플 선별 → 학습 데이터 추가 → 재학습
이 대회에서 Pseudo Labeling이 특히 효과적인 이유는 도메인 갭 때문입니다.
Train/Dev는 고정 환경이지만 Test는 다양한 환경으로 구성됩니다. Pseudo Labeling을 통해 Test 도메인의 이미지를 학습 데이터에 직접 포함시킬 수 있습니다.
5.2 구현
PSEUDO_CSV = 'inference_output/submission_v4_1seed_5models.csv'
PSEUDO_THRESHOLD = 0.98
def load_pseudo_labels():
sub = pd.read_csv(PSEUDO_CSV)
sub['max_prob'] = sub[['unstable_prob', 'stable_prob']].max(axis=1)
sub['label'] = np.where(sub['unstable_prob'] > 0.5, 'unstable', 'stable')
pseudo_df = sub[sub['max_prob'] > PSEUDO_THRESHOLD][['id', 'label']].copy()
pseudo_df['data_dir'] = f'{OPEN_DIR}/test'
pseudo_df['source'] = 'pseudo'
return pseudo_df
K-Fold는 원본 1,100개에 대해서만 수행하며, Pseudo 데이터는 학습 fold에만 추가합니다.
검증 데이터에는 절대로 포함시키지 않아 Val LogLoss의 신뢰성을 유지합니다.
for fold, (train_idx, val_idx) in enumerate(skf.split(all_df, all_df['label']), 1):
train_fold = all_df.iloc[train_idx]
val_fold = all_df.iloc[val_idx] # clean only
# Pseudo는 학습 데이터에만 추가
train_fold = pd.concat([train_fold, pseudo_df]).reset_index(drop=True)
print(f" 학습: 원본 {len(train_idx)}개 + pseudo {len(pseudo_df)}개 = {len(train_fold)}개")
print(f" 검증: {len(val_fold)}개 (clean only)")
5.3 Iterative Pseudo Labeling
더 좋은 모델로 생성한 pseudo 데이터는 품질이 더 높습니다.
이를 반복하면 매 라운드마다 pseudo 품질이 개선되는 선순환 구조를 만들 수 있습니다.
v3 (3seed, 15모델)
→ submission_v3.csv 생성
→ LB 0.0601
v4 seed42 학습 (v3 결과를 pseudo로 사용, threshold=0.98)
→ submission_v4_1seed_5models.csv 생성
→ LB 0.0559 ← 최고 성능
v4 seed42 + seed123 앙상블 시도
→ LB 0.0591 ← 성능 악화
seed123을 추가하였을 때 성능이 오히려 하락한 이유는, seed123 모델 자체의 품질이 seed42보다 낮아 pseudo 노이즈를 증폭시켰기 때문으로 분석됩니다.
앙상블이 항상 성능을 향상시키지는 않으며, 모델 품질 차이가 클 경우 오히려 악영향을 줄 수 있습니다.
6. 실수와 교훈
실수 1 – 추론 파일 덮어쓰기
inference_kfold_v4.py를 재실행하면서 기존 결과 파일이 덮어씌워졌습니다.
중요한 추론 결과는 파일명에 날짜나 버전 정보를 포함하거나, 별도로 백업해두어야 합니다.
실수 2 – Dev 기반 Temperature Scaling
처음에 Dev 데이터로 T를 최적화하여 Dev LogLoss가 0.0000이 되었습니다.
탐색 범위도 (0.1, 5.0)으로 너무 넓게 설정한 것이 원인이었습니다.
OOF logits으로 변경하고 탐색 범위를 (0.8, 1.5)로 제한하여 해결하였습니다.
실수 3 – RandomResizedCrop 적용
강한 증강이 항상 좋은 것은 아닙니다. 데이터의 도메인 특성을 먼저 이해하고 증강 전략을 결정해야 합니다. 이 대회에서는 구조물 전체를 입력으로 제공하는 것이 핵심 조건이었습니다.
최종 결과
| 버전 | 방법 | LB LogLoss |
|---|---|---|
| v3 – 3seed 15모델 | K-Fold + Multi-Seed 앙상블 + TTA | 0.0601 |
| v4 – seed42 5모델 | + Pseudo Labeling | 0.0559 |
| v4 – seed42+123 10모델 | 앙상블 추가 | 0.0591 (악화) |
[ 오늘의 정리 ]
- Dual-View 아키텍처: front/top을 각각 인코딩하여 concat → ImageNet 사전학습 최대 활용
- K-Fold 교차검증: Train+Dev 1,100개 통합, 검증은 항상 clean 데이터만 사용
- OOF Temperature Scaling: Dev가 아닌 OOF logits으로 T 최적화 → Data Leakage 방지
- TTA: 6가지 증강 × N개 모델로 예측 안정성 향상
- Pseudo Labeling: Test 도메인 이미지를 학습에 직접 포함 → 도메인 갭 해소에 효과적
- 앙상블 주의: 품질이 낮은 모델을 추가하면 오히려 성능이 하락할 수 있음
'개발 기록실 > 실험 & 구현' 카테고리의 다른 글
| A3C & A2C – CartPole 구현 코드 뜯어보기 (PyTorch + 멀티프로세싱) (0) | 2026.03.17 |
|---|---|
| 랜덤 벽 GridWorld에서 TD Learning으로 상태가치 함수 배우기 (0) | 2026.03.13 |
| REINFORCE로 CartPole-v1 학습하기 – 정책 기반 에이전트 실습 (0) | 2026.03.13 |
| [YOLOv8 + RNN] 편의점/매장 이상행동(전도·파손) 탐지 파이프라인 만들기 (0) | 2026.03.09 |
| [OpenCV + Machine Learning] Kaggle 주조 제품 불량 이미지를 이용한 Random Forest 분류기 만들기 (0) | 2026.03.09 |