Kaggle CSIRO Image2Biomass 대회 5등 솔로 금메달 솔루션

이번 Kaggle의 CSIRO Image2Biomass 예측 대회는 농업 분야에서 AI를 활용한 매우 유의미한 컴퓨터 비전 문제였다. 개인적으로 처음으로 참여한 CV 대회였으며, 최종적으로 솔로 5등(Public LB: 0.76)을 기록할 수 있었다.

문제 개요

목표는 목장 이미지를 입력으로 받아 아래 5가지 생물량(biomass) 성분을 예측하는 것이었다:

  • Dry_Green_g – 마른 녹색 식물(클로버 제외)
  • Dry_Dead_g – 마른 고사물질
  • Dry_Clover_g – 마른 클로버 생물량
  • GDM_g – 녹색 건조물질
  • Dry_Total_g – 총 마른 생물량

데이터 특성은 다음과 같았다:

  • 학습 데이터가 약 800장으로 소규모
  • 원본 이미지는 2000x1000 픽셀
  • 학습과 테스트셋 간 도메인 차이 존재
  • 호주의 여러 주(NSW, Tas, Vic, WA)에서 수집
  • 물리적 제약 조건 존재 (예: Dead = Total - GDM)

주요 기법 및 구현

1. 이미지 전처리 - 타임스탬프 제거

원본 이미지에는 오렌지 색상의 타임스탬프가 포함되어 있어 이를 HSV 공간에서 감지하고 Inpainting 기법으로 제거하였다.

def remove_timestamp(img):
    hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
    lower_orange = np.array([5, 150, 150])
    upper_orange = np.array([25, 255, 255])
    mask = cv2.inRange(hsv, lower_orange, upper_orange)

    kernel = np.ones((3, 3), np.uint8)
    mask = cv2.dilate(mask, kernel, iterations=2)

    if np.sum(mask) > 0:
        img = cv2.inpaint(img, mask, 3, cv2.INPAINT_TELEA)

    return img

2. 데이터 증강(TTA 스타일)

소량의 학습 데이터로 인해 과적합 방지를 위해 다양한 변환을 적용했다.

def get_augmentation_pipelines(size):
    base_normalize = A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])

    flip_h = A.Compose([
        A.Resize(size, size),
        A.HorizontalFlip(p=1),
        base_normalize,
        ToTensorV2()
    ])

    flip_v = A.Compose([
        A.Resize(size, size),
        A.VerticalFlip(p=1),
        base_normalize,
        ToTensorV2()
    ])

    rot90 = A.Compose([
        A.Resize(size, size),
        A.Rotate(limit=(90, 90), p=1, border_mode=0),
        base_normalize,
        ToTensorV2()
    ])

    raw = A.Compose([
        A.Resize(size, size),
        base_normalize,
        ToTensorV2()
    ])

    return [flip_h, flip_v, rot90, raw]

3. 멀티뷰 입력 구조

입력 이미지를 왼쪽 반절, 오른쪽 반절, 전체 이미지 세 가지로 나누어 각각 처리하여 지역적 및 전역 정보를 함께 학습하도록 구성했다.

class MultiViewDataset(Dataset):
    def __getitem__(self, idx):
        image = self.load_image(self.paths[idx])
        image = remove_timestamp(image)

        h, w = image.shape[:2]
        mid = w // 2
        left_part = image[:, :mid]
        right_part = image[:, mid:]
        full_img = image.copy()

        left_part = self.transform(image=left_part)["image"]
        right_part = self.transform(image=right_part)["image"]
        full_img = self.transform(image=full_img)["image"]

        return left_part, right_part, full_img, label, eval_label, species_info

4. Mixture of Experts(MoE) 모델 아키텍처

입력에 따라 적절한 전문가(expert) 네트워크를 선택하여 예측 성능을 향상시켰다.

class MoEHead(nn.Module):
    def __init__(self, input_dim, hidden_dim, experts_count, output_dim=1, drop_rate=0.2):
        super().__init__()
        self.num_experts = experts_count
        
        self.router = nn.Sequential(
            nn.LayerNorm(input_dim),
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Linear(256, experts_count)
        )
        
        self.experts = nn.ModuleList([
            nn.Sequential(
                nn.LayerNorm(input_dim),
                nn.Linear(input_dim, hidden_dim),
                nn.ReLU(),
                nn.Dropout(drop_rate),
                nn.Linear(hidden_dim, 256),
                nn.ReLU(),
                nn.Linear(256, 2)
            ) for _ in range(experts_count)
        ])

    def forward(self, features):
        gate_logits = self.router(features)
        weights = F.softmax(gate_logits, dim=1).unsqueeze(-1)
        expert_outputs = torch.stack([expert(features) for expert in self.experts], dim=1)
        combined_output = (expert_outputs * weights).sum(dim=1)

        mean_pred = combined_output[:, 0:1]
        var_logit = combined_output[:, 1:2]
        variance = F.softplus(var_logit) + 1e-6

        return mean_pred, variance

5. 물리 일관성 손실 함수

R² 점수 특성을 고려하여 Gaussian Negative Log-Likelihood 손실을 사용했고, 목표 변수 간 물리적 관계도 손실에 통합했다.

class PhysicsAwareLoss(nn.Module):
    def __init__(self):
        super().__init__()
        self.weights = {
            "total": 0.45,
            "gdm": 0.2,
            "green": 0.1,
            "dead": 0.1,
            "clover": 0.1
        }

    def forward(self, predictions, ground_truth):
        loss_total = gaussian_nll(predictions.total_mean, ground_truth.total, predictions.total_var)
        loss_gdm = gaussian_nll(predictions.gdm_mean, ground_truth.gdm, predictions.gdm_var)
        loss_green = gaussian_nll(predictions.green_mean, ground_truth.green, predictions.green_var)

        # Dead = Total - GDM
        dead_mean = predictions.total_mean - predictions.gdm_mean
        dead_var = predictions.total_var + predictions.gdm_var
        dead_gt = ground_truth.total - ground_truth.gdm
        loss_dead = gaussian_nll(dead_mean, dead_gt, dead_var)

        # Clover = GDM - Green
        clover_mean = predictions.gdm_mean - predictions.green_mean
        clover_var = predictions.gdm_var + predictions.green_var
        clover_gt = ground_truth.gdm - ground_truth.green
        loss_clover = gaussian_nll(clover_mean, clover_gt, clover_var)

        weighted_loss = (
            self.weights["total"] * loss_total +
            self.weights["gdm"] * loss_gdm +
            self.weights["green"] * loss_green +
            self.weights["dead"] * loss_dead +
            self.weights["clover"] * loss_clover
        )

        return weighted_loss

6. 후처리 전략

특정 주(WA)에 대한 예외 처리와 값 범위 제한 등을 통해 예측 결과를 보정했다.

def postprocess_predictions(df):
    wa_condition = df['predicted_state'] == 'WA'
    df.loc[wa_condition, 'Dry_Dead_g'] = 0
    df.loc[wa_condition, 'Dry_Total_g'] = df.loc[wa_condition, 'GDM_g']

    state_ranges = {
        'Tas': {'min': 0.0, 'max': 71.79},
        'NSW': {'min': 0.0, 'max': 10.10},
        'WA':  {'min': 0.0, 'max': 58.88},
        'Vic': {'min': 0.0, 'max': 67.90}
    }

    for state_key in state_ranges:
        condition = df['predicted_state'] == state_key
        for col in ['Dry_Clover_g']:
            min_val = state_ranges[state_key][col]['min']
            max_val = state_ranges[state_key][col]['max']
            df.loc[condition, col] = df.loc[condition, col].clip(lower=min_val, upper=max_val)

    return df

모델 앙상블

총 6개의 서로 다른 모델을 가중 평균하여 최종 제출물을 생성했다. TTA 추론과 병렬 GPU 실행을 통해 효율적인 추론을 수행했다.

모델비중특징
v1420.0State/Species 분류용
v1480.15Meta embedding 포함
v1470.20기본 구조(seed=2026)
v1740.25랜덤 증강
v1640.20TTA 기반 증강
v1770.20손실 가중치 조정

하이퍼파라미터 설정

  • 백본: vit_large_patch16_dinov3_qkvb
  • 이미지 크기: 1024x1024
  • 배치 크기: 4
  • 초기 학습률: 2e-4 → warmup 이후 5e-5
  • 백본 LR 배율: 0.1
  • 에포크 수: 25
  • MoE Expert 수: 4
  • Optimizer: AdamW
  • Weight decay: 1e-4

실패한 접근

  • Huge 모델은 과적합 우려로 성능 저하
  • UNet, Mamba 등 복잡한 구조는 효과 미흡
  • 단순 ratio 예측은 성능 하락
  • 30 에포크 이상 학습 시 Public LB는 상승했으나 Private LB는 감소

결론

이번 대회에서는 다양한 실험과 데이터 분석을 통해 좋은 성능을 달성할 수 있었다. 특히 물리적 제약을 반영한 손실 함수 설계와 멀티뷰 입력, MoE 구조 등의 기법이 효과적이었다. 코드는 공개되었으며, 자세한 내용은 관련 노트북을 참조하면 된다.

태그: kaggle CV Computer Vision Agriculture AI

8월 15일 09:13에 게시됨