Skip to content

lsngmin/crop-yield-prediction

Repository files navigation

농작물 수확량 예측 API

ML 모델을 Spring Boot 단일 컨테이너에서 직접 추론하는 농업 도메인 API

환경/관리 조건 9개 피처를 받아 RandomForest 회귀 모델로 작물 수확량을 예측한다. ML 모델은 ONNX로 export하고 Spring Boot가 ONNX Runtime Java로 직접 추론

image
  • 단일 컨테이너 ML 추론: Python 사이드카 없이 JVM에서 직접 ONNX 추론
  • 배치 throughput 24.7× 개선: /predict/batch에서 [N, 24] 텐서를 단일 ONNX 호출로 처리해 19.9 → 490.65 predictions/s
  • 모델 크기 최적화: 142MB → 24.6MB, MAE 0.4031 → 0.4015 개선
  • 트러블슈팅: ONNX TreeEnsembleRegressor float32 제약 대응

Quick Start

실행

# Docker
docker build -t crop-yield-api .
docker run --rm -p 8080:8080 crop-yield-api

# 또는 로컬 (개발 시)
./gradlew bootRun

서버 시작 후 Swagger: http://localhost:8080/swagger-ui.html

POST /predict — 단일 추론

요청:

curl -X POST http://localhost:8080/predict \
  -H "Content-Type: application/json" \
  -d '{
    "rainfallMm": 500.0,
    "temperatureCelsius": 25.0,
    "fertilizerUsed": true,
    "irrigationUsed": true,
    "daysToHarvest": 120,
    "crop": "Wheat",
    "region": "South",
    "soilType": "Loam",
    "weatherCondition": "Rainy"
  }'

응답:

{
  "predictedYield": 5.732,
  "modelVersion": "v1.0"
}

POST /predict/batch — 배치 추론

여러 케이스를 한 번에 처리. [N, 24] 텐서로 단일 ONNX 호출 → 단건 대비 약 25배 throughput.

요청:

curl -X POST http://localhost:8080/predict/batch \
  -H "Content-Type: application/json" \
  -d '{"items": [
    {"rainfallMm":500.0,"temperatureCelsius":25.0,"fertilizerUsed":true,"irrigationUsed":true,"daysToHarvest":120,"crop":"Wheat","region":"South","soilType":"Loam","weatherCondition":"Rainy"},
    {"rainfallMm":200.0,"temperatureCelsius":30.0,"fertilizerUsed":false,"irrigationUsed":true,"daysToHarvest":150,"crop":"Rice","region":"East","soilType":"Clay","weatherCondition":"Sunny"}
  ]}'

응답:

{
  "predictions": [
    {"predictedYield": 5.732, "modelVersion": "v1.0"},
    {"predictedYield": 2.751, "modelVersion": "v1.0"}
  ],
  "modelVersion": "v1.0",
  "count": 2
}

RandomForest Model

데이터

Agriculture Crop Yield (Kaggle) — 합성 100만 rows, 9 피처 + 타겟

kaggle datasets download -d samuelotiattakorah/agriculture-crop-yield -p data/ --unzip

학습 결과

notebooks/baseline.ipynb — EDA, 학습, 평가, ONNX export 전 과정.

지표
MAE 0.4015 ton/ha
RMSE 0.5032 ton/ha

상위 3개 피처(Rainfall, Fertilizer_Used, Irrigation_Used)가 모델 결정의 92.7% 차지.

Feature Importance

데이터의 한계

합성 데이터셋이라서 실제 농업 데이터의 노이즈/결측치/계절성을 반영하지 않는다. 베이스라인 모델 검증 목적이며, 실 운영엔 도메인 데이터 추가학습이 필요하다.

백엔드 구현 세부사항

Spring Boot 패턴

  • @ConfigurationProperties (record): yaml의 app.model.* 값을 record로 타입 안전 매핑 — ModelProperties.java

  • 모델 startup 로딩: OrtEnvironment/OrtSession을 빈으로 등록해 startup 시 1회 초기화. 첫 요청 latency 제거 + 싱글톤 보장 — ModelLoader.java

  • @RestControllerAdvice 3단계 매핑: Validation 예외(400) / 비즈니스 예외(400) / 일반 예외(500, 메시지 마스킹) — GlobalExceptionHandler.java

  • JNI 자원 관리: OnnxTensor, OrtSession.Result는 GC 대상이 아닌 네이티브 자원 → 중첩 try-with-resources로 누수 방지

    try (OnnxTensor tensor = OnnxTensor.createTensor(env, input)) {
      try (OrtSession.Result result = session.run(Map.of(inputName, tensor))) {
          float[][] output = (float[][]) result.get(0).getValue();
          return output[0][0];
      }
    }```
  • Observability (Micrometer + Actuator): /actuator/metrics로 추론 호출 수/성공/실패 카운터, 추론 latency Timer 노출. 메트릭 cardinality 관리를 위해 카테고리 태그 미사용 — InferenceMetrics.java

    노출 메트릭:

    • crop_yield_predict_requests_total
    • crop_yield_predict_success_total
    • crop_yield_predict_errors_total
    • crop_yield_inference_duration

입력 → 추론 흐름

  1. 카테고리 값 검증 (메타데이터 화이트리스트)
  2. 9 필드 → 24 float 배열 변환 (불리언 → int, 카테고리 → one-hot)
  3. featureOrder 순서대로 정렬 (학습 컬럼 순서 일치)
  4. ONNX 추론 → 단일 float 결과 추출

PredictService.java

Performance

k6 기반 로컬 부하 테스트. 측정 환경: MacBook Air (Apple Silicon), Docker 단일 컨테이너.

단건 추론 (POST /predict)

지표
p50 3.55 ms
p95 7.30 ms
p99 25.95 ms
error rate 0.00%

배치 추론 (POST /predict/batch, batch size = 50)

지표
p50 13.17 ms
p95 36.66 ms
p99 184.67 ms
predictions/s 490.65

단건 vs 배치 비교

단건 배치 (50) 비율
p50 기준 prediction당 처리 시간 3.55 ms 0.26 ms 13.5× 낮음
predictions/s 19.9 490.65 24.7× 높음

배치는 [N, 24] 텐서를 단일 ONNX 호출로 처리. HTTP/JSON 직렬화, JNI 경계 통과, ONNX 추론 모두 1회로 압축되어 throughput 약 25배 개선.

한계: 로컬 단일 머신 측정이라 절대값 의미 제한적. 상대 비교(단건 vs 배치)에 의미. 프로덕션 측정은 별도 호스트 + 클라우드 환경에서 수행 예정.

자세한 환경/시나리오/임계값: performance/results/README.md

테스트 전략

JUnit 5 + Mockito 단위 테스트 3개:

# 대상 방법
1 Service 정상 케이스 ONNX 의존성 mock. OnnxTensor.createTensor 정적 메서드는 MockedStatic으로 모킹
2 Service 예외 케이스 잘못된 카테고리 → IllegalArgumentException (인코딩 도달 전 차단 확인)
3 Controller validation @WebMvcTest + MockMvc로 웹 레이어 격리. 필드 누락 → 400 응답 검증

-> ONNX 파일 없이 동작하는 순수 단위 테스트다. 모델 변경/재학습 시에도 테스트 영향 X

왜 이렇게 만들었나

1. 왜 RandomForest?

농업 데이터 특성상 강수량/온도 같은 환경 변수와 수확량 사이엔 비선형/임계 패턴이 흔하다 (예: 강수량 X mm 이상에서 효과). RandomForest는 트리 분기점이 곧 임계값이라 이런 패턴을 자연스럽게 학습한다. 또한 합성 데이터의 노이즈에 강건하고, 9개 피처 스케일이 제각각(rainfall mm, temperature ℃, days)이라 스케일링 없이 바로 학습 가능한 점도 베이스라인으로 적합했다.

2. 왜 ONNX (pickle 아님)?

스펙상 단일 컨테이너 제약 때문. pickle을 쓰면 Python 사이드카가 별도로 필요해 docker-compose 멀티 컨테이너로 가게 된다. ONNX 포맷은 ONNX Runtime Java를 통해 JVM 내에서 직접 추론할 수 있어 단일 컨테이너 구성 유지가 가능하다.

float64 입력으로 더 높은 정밀도를 시도했으나, ONNX의 TreeEnsembleRegressor 연산자가 float32 출력을 강제하는 스펙 한계로 type mismatch가 발생했다. 트리 앙상블 모델 공통의 ONNX ML opset 한계라 판단하고 float32를 채택했다. sklearn 대비 검증 결과 최대 오차가 약 1e-6로 운영상 무시 가능한 수준으로 확인했다.

3. ONNX 변환 시 모델 크기 제약 해결

베이스라인 학습 후 ONNX 변환 시 모델 크기 문제로 두 번 재학습하며 배포 가능한 모델로 변환하였다.

설정 MAE 파일 크기 비고
default (max_depth=None) 0.4115 GB 추정 변환 미완료
1차 제약 (depth=20, leaf=20) 0.4031 142 MB GitHub 100MB 초과
최종 (n=50, depth=15, leaf=50) 0.4015 24.6 MB

정규화를 강하게 적용할수록 모델 크기뿐 아니라 MAE도 함께 개선되는 결과가 나왔다. 합성 데이터의 강한 신호 특성상 기본값(max_depth=None)으로 학습한 트리는 노이즈까지 외워 과적합한 것으로 추정한다.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors