이번 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 실행을 통해 효율적인 추론을 수행했다.
| 모델 | 비중 | 특징 |
|---|---|---|
| v142 | 0.0 | State/Species 분류용 |
| v148 | 0.15 | Meta embedding 포함 |
| v147 | 0.20 | 기본 구조(seed=2026) |
| v174 | 0.25 | 랜덤 증강 |
| v164 | 0.20 | TTA 기반 증강 |
| v177 | 0.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 구조 등의 기법이 효과적이었다. 코드는 공개되었으며, 자세한 내용은 관련 노트북을 참조하면 된다.