Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ tasks.withType(Test).configureEach {
// CI 가 통과하는 건 러너가 아직 이전 Engine 이기 때문이라, 러너가 올라가면 CI 도 같은 상태가 된다.
// 1.44 는 Engine 25 이상이 지원하므로 로컬·CI 모두 안전하다. 필요하면 -Papi.version= 으로 덮어쓴다.
systemProperty 'api.version', project.findProperty('api.version') ?: '1.44'

// 통합 테스트가 늘면서 Spring 컨텍스트가 14개까지 떠, 기본 힙으로는 OutOfMemory 로 워커가 죽었다.
// 컨텍스트마다 JPA·커넥션풀·캐시를 들고 있어 개수가 곧 메모리다.
maxHeapSize = '2g'

// 동시에 살아 있는 컨텍스트 수를 제한한다. 기본값(32)이면 한 번 뜬 컨텍스트가 끝까지 캐시에 남아
// 테스트 클래스를 추가할 때마다 메모리가 계단식으로 늘어난다. 넘치면 오래된 컨텍스트를 닫는다.
systemProperty 'spring.test.context.cache.maxSize', '6'
}

tasks.named('test') {
Expand Down
53 changes: 53 additions & 0 deletions docs/features/facility-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,54 @@ flowchart TD
그럴듯한 숫자를 보여주는 쪽이 사용자 경험은 좋아 보이지만, 그 숫자를 믿고 다른 시설을 포기한
부모에게는 피해입니다.

## 예측이 맞는지 스스로 검증한다 (V20)

확률을 보여주면서 **그 확률이 실제로 맞는지 확인한 적이 없었습니다.** 예측을 기록해 몇 달 기다릴
필요는 없습니다 — 정원 관측 시계열이 이미 있으므로 과거로 돌아가 같은 계산을 다시 할 수 있습니다.

```
기준일(과거 어느 날)
→ 그날 이전 관측만 넘겨 예측을 다시 계산 ← 이후 관측을 섞으면 미래를 보고 예측한 셈
→ 기준일 다음 N개월 관측으로 실제 결과 확인 (자리가 났는가)
→ (예측 확률, 실제 결과) 쌍을 모아 집계
```

기준일을 30일 간격으로 옮기며 표본을 모읍니다. 라이브 예측과 **같은 계산기**
(`AdmissionForecastCalculator`)를 씁니다. 검증용으로 따로 구현하면 무엇을 검증했는지 알 수 없습니다.

### 무엇을 재는가

| 지표 | 뜻 |
|------|-----|
| 확률대별 실제 적중률 | "60~80% 로 예측한 건 중 실제로 자리가 난 비율". 예측이 정직한지 보여준다 |
| Brier 점수 | 낮을수록 정확. 0=완벽, 0.25=동전 던지기 |
| 기준선 Brier | 예측하지 않고 **항상 평균 발생률로 답했을 때**의 점수 |

기준선을 함께 기록하는 이유: 예측이 이보다 못하면 확률을 보여줄 근거가 없습니다.
"평균으로 답하기" 보다 나은지는 응답의 `betterThanBaseline` 로 나갑니다.

시설 이력은 시설당 한 번만 읽어 모든 기간에 재사용합니다. 기간마다 다시 읽으면 전국 규모(수만 곳)에서
주간 작업이 쿼리 수만 건으로 늘어납니다. 대상 시설 수에는 상한이 있습니다.

측정 기간은 1·3·6개월입니다. 6개월만 재면 관측이 6개월 넘게 쌓인 시설이 있어야 표본이 생겨,
수집 초기에는 아무것도 검증할 수 없습니다.

### 표본이 적으면 보여주지 않는다

| 조건 | 처리 |
|------|------|
| 전체 표본 30건 미만 | 정확도를 아예 내보내지 않는다 |
| 해당 확률대 표본 10건 미만 | 구간 적중률만 비운다 |
| 검증 구간에 관측 없음 | 표본에서 버린다 (실제 결과를 알 수 없다) |

"표본 3건 중 3건 적중 = 100%" 같은 숫자는 근거 없는 확률보다 더 나쁘게 오해를 만듭니다.

### 어디로 나가는가

- `GET /facilities/{id}/admission-forecast` 응답의 `accuracy` — 지금 보여주는 확률이 속한 구간의 실제 적중률
- `GET /facilities/forecast-accuracy` (공개) — 기간별 최신 측정값과 구간표 전체
- 매주 일요일 04:30 자동 측정, `POST /api/admin/sync/forecast-accuracy/measure` 로 수동 실행

## 시설 인기도

충원율 **추이**로 판단합니다. 현재 충원율만 보면 정원이 작은 시설이 항상 높게 나옵니다.
Expand Down Expand Up @@ -170,6 +218,8 @@ flowchart TD
| GET | `/facilities/popular` | 공개 |
| GET | `/facilities/statistics` | 공개 |
| GET | `/facilities/{facilityId}/admission-forecast` | 공개 |
| GET | `/facilities/forecast-accuracy` | 공개 |
| POST | `/api/admin/sync/forecast-accuracy/measure` | 관리자 |
| GET | `/facilities/search` | 인증 |
| POST | `/facilities/{facilityId}/waitlist` | 인증 |
| GET | `/facilities/waitlist/me` | 인증 |
Expand All @@ -184,10 +234,13 @@ flowchart TD
| `app.facility-vacancy.min-interval-days` | 14 | 같은 사람에게 다시 알리기까지 최소 간격 |
| `app.facility-vacancy.min-increase` | 1 | 이만큼 늘어야 알림 |
| `app.scheduler.public-data.vacancy-cron` | `0 30 9 * * *` | 실행 시각 (시설 동기화 이후) |
| `app.scheduler.forecast-backtest.cron` | `0 30 4 * * SUN` | 예측 정확도 측정 시각 |
| `app.forecast.backtest.max-facilities` | 2000 | 백테스트로 볼 시설 수 상한 (정확도는 표본 추정이라 전수를 볼 필요가 없다) |

## 미해결

| 항목 | 내용 |
|------|------|
| 반별 정원 | 공공데이터가 주지 않습니다. 시설 직접 입력이나 크라우드 제보가 필요합니다 |
| 대기 순번 검증 | 사용자가 입력한 순번을 검증할 방법이 없습니다 |
| 정확도 표본 | 관측이 쌓인 만큼만 검증됩니다. 수집 초기에는 1개월 기간만 표본이 모입니다 |
1 change: 1 addition & 0 deletions docs/reference/access-control-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ flowchart TD
| `/facilities/{id}/view` | 조회수 증가 |
| `/facilities/{id}/rating` (GET) | 평점 조회 |
| `GET /facilities/{id}`, `/facilities/{id}/with-reviews`, `/facilities/{id}/reviews` | 시설 상세·공개 리뷰. 프런트가 로그인 전에도 보여 준다 |
| `GET /facilities/forecast-accuracy` | 예측 정확도 측정값(백테스트 결과) |
| `GET /facilities/{id}/admission-forecast`, `/facilities/{id}/popularity`, `/facilities/{id}/waitlist/stats` | 공공데이터 기반 예측·집계 |
| `POST /facilities/search`, `POST /facilities/advanced-search` | 조건을 본문으로 받는 조회 |
| `/api/public/care-facilities/**` | 공공데이터 조회 |
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/carecode/core/ops/sync/SyncJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public enum SyncJob {
POLICY_CHANGE_NOTICE("policy-change-notice", "정책 변경 알림", 36, false),
FACILITY_VACANCY_NOTICE("facility-vacancy-notice", "빈자리 알림", 36, false),
POLICY_DEADLINE_NOTICE("policy-deadline-notice", "마감 임박 알림", 36, false),
BENEFIT_REPORT_SOLICIT("benefit-report-solicit", "실수령액 제보 요청", 192, false);
BENEFIT_REPORT_SOLICIT("benefit-report-solicit", "실수령액 제보 요청", 192, false),
FORECAST_BACKTEST("forecast-backtest", "입소 예측 정확도 측정", 192, false);

private final String code;
private final String label;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.carecode.core.scheduler;

import com.carecode.core.ops.sync.SyncJob;
import com.carecode.core.ops.sync.SyncRunTracker;
import com.carecode.domain.careFacility.service.ForecastBacktestService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
* 입소 예측 정확도 측정. 주 1회.
*
* <p>정원 관측이 주 1회 들어오므로 그보다 자주 돌릴 이유가 없다. 시설 동기화가 끝난 뒤에 돌려
* 그 주의 관측까지 검증에 넣는다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ForecastBacktestScheduler {

private final ForecastBacktestService backtestService;
private final SyncRunTracker tracker;

@Scheduled(cron = "${app.scheduler.forecast-backtest.cron:0 30 4 * * SUN}", zone = "Asia/Seoul")
public void measureAccuracy() {
tracker.track(SyncJob.FORECAST_BACKTEST, () -> backtestService.runAll());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.carecode.domain.admin.controller;

import com.carecode.core.ops.sync.SyncFreshnessService;
import com.carecode.domain.careFacility.service.ForecastBacktestService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
Expand All @@ -27,6 +28,7 @@
public class AdminSyncStatusController {

private final SyncFreshnessService freshnessService;
private final ForecastBacktestService backtestService;

@GetMapping("/status")
@Operation(summary = "주기 작업 상태", description = "작업별 마지막 성공 시각, 경과 시간, 신선도 기준 초과 여부")
Expand All @@ -38,4 +40,31 @@ public ResponseEntity<Map<String, Object>> status() {
body.put("staleCount", jobs.stream().filter(SyncFreshnessService.JobFreshness::isStale).count());
return ResponseEntity.ok(body);
}

/**
* 예측 정확도 수동 측정.
*
* <p>스케줄러는 주 1회라, 새 관측이 들어온 뒤 결과를 바로 보고 싶을 때 쓴다.
* 표본이 부족한 기간은 결과에 포함되지 않는다(없는 정확도를 만들어내지 않는다).
*/
@org.springframework.web.bind.annotation.PostMapping("/forecast-accuracy/measure")
@Operation(summary = "예측 정확도 측정 실행", description = "과거 관측으로 백테스트를 돌려 기간별 정확도를 다시 계산")
public ResponseEntity<Map<String, Object>> measureForecastAccuracy() {
var results = backtestService.runAll();

Map<String, Object> body = new LinkedHashMap<>();
body.put("measured", results.size());
body.put("results", results.stream().map(r -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("horizonMonths", r.getHorizonMonths());
row.put("samples", r.getSamples());
row.put("facilities", r.getFacilities());
row.put("actualRate", r.getActualRate());
row.put("brierScore", r.getBrierScore());
row.put("baselineBrierScore", r.getBaselineBrierScore());
row.put("betterThanBaseline", r.betterThanBaseline());
return row;
}).toList());
return ResponseEntity.ok(body);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class CareFacilityFacade {
private final CareFacilityBookingService bookingService;
private final AdmissionForecastService admissionForecastService;
private final FacilityPopularityService facilityPopularityService;
private final com.carecode.domain.careFacility.service.ForecastAccuracyService forecastAccuracyService;

@Transactional(readOnly = true)
public List<CareFacilityInfo> getAllCareFacilities(int page, int size) {
Expand Down Expand Up @@ -91,6 +92,12 @@ public void updateRating(Long id, Double rating) {
careFacilityService.updateRating(id, rating);
}

/** 입소 예측 정확도(측정된 기간별 최신 결과). 공개 API. */
@Transactional(readOnly = true)
public java.util.List<com.carecode.domain.careFacility.dto.response.ForecastAccuracyResponse> getForecastAccuracy() {
return forecastAccuracyService.latestByHorizon(com.carecode.domain.careFacility.service.ForecastBacktestService.MEASURED_HORIZONS);
}

@Transactional(readOnly = true)
public CareFacilityStatsResponse getFacilityStats() {
return careFacilityService.getFacilityStats();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,17 @@ public ResponseEntity<AdmissionForecastResponse> forecastAdmission(
return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths));
}

// 예측 정확도 (공개)
@GetMapping("/forecast-accuracy")
@LogExecutionTime
@Operation(summary = "입소 예측 정확도",
description = "과거 관측으로 같은 예측을 다시 계산해 실제 결과와 비교한 측정값. "
+ "확률대별 실제 적중률과, 항상 평균으로 답했을 때(기준선)와의 비교를 함께 준다. "
+ "표본이 부족한 기간은 목록에 없다.")
public ResponseEntity<List<com.carecode.domain.careFacility.dto.response.ForecastAccuracyResponse>> getForecastAccuracy() {
return ResponseEntity.ok(careFacilityFacade.getForecastAccuracy());
}

// 충원율 기반 인기도
@GetMapping("/{facilityId}/popularity")
@LogExecutionTime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ public class AdmissionForecastResponse {

/** 사용자에게 보여줄 근거 문장. */
private List<String> reasons;

/**
* 이 확률이 과거에 얼마나 맞았는지. 표본이 부족하면 null 이다.
* 확률만 보여주면 사용자는 믿을지 판단할 근거가 없다.
*/
private ForecastAccuracyResponse accuracy;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.carecode.domain.careFacility.dto.response;

import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

/**
* 입소 예측이 실제로 얼마나 맞았는지.
*
* <p>확률만 보여주면 사용자는 그 숫자를 믿을지 판단할 근거가 없다. 과거 관측으로 같은 계산을 다시 돌려
* 측정한 적중률을 함께 준다. 표본이 적으면 숫자를 만들지 않고 비운다.
*/
@Getter
@Builder
public class ForecastAccuracyResponse {

/** 측정 실행일. */
private final LocalDate measuredAt;

private final int horizonMonths;

/** 검증에 쓴 예측 건수. */
private final int samples;

private final int facilities;

/** 표본에서 실제로 자리가 난 비율 (0~1). */
private final double actualRate;

/** 낮을수록 정확. 0=완벽, 0.25=동전 던지기. */
private final double brierScore;

/** 항상 평균 발생률로 답했을 때의 점수. */
private final double baselineBrierScore;

/** 예측이 "평균으로 답하기" 보다 나은가. false 면 화면에서 확률을 강조하지 않는 편이 맞다. */
private final boolean betterThanBaseline;

/** 지금 보여주는 확률이 속한 구간의 실제 적중률. 그 구간 표본이 적으면 null. */
private final Bucket matchedBucket;

/** 전체 구간표. 공개 통계에서 쓴다. 상세 응답에서는 생략될 수 있다. */
private final List<Bucket> calibration;

@Getter
@Builder
public static class Bucket {
/** 구간 시작(%)·끝(%). 예: 60~80 */
private final int from;
private final int to;
private final int samples;
private final int actualTrue;
/** 이 구간으로 예측한 건 중 실제로 자리가 난 비율 (0~1). 표본 0이면 null. */
private final Double actualRate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.carecode.domain.careFacility.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;

/** 입소 예측 정확도 측정 결과 한 건(기간별 1회 실행). */
@Entity
@Table(name = "TBL_FORECAST_ACCURACY")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ForecastAccuracy {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ID")
private Long id;

@Column(name = "RUN_DATE", nullable = false)
private LocalDate runDate;

@Column(name = "HORIZON_MONTHS", nullable = false)
private int horizonMonths;

@Column(name = "SAMPLES", nullable = false)
private int samples;

@Column(name = "FACILITIES", nullable = false)
private int facilities;

/** 낮을수록 정확하다. 0 이 완벽, 0.25 가 동전 던지기 수준. */
@Column(name = "BRIER_SCORE", nullable = false)
private double brierScore;

/** 항상 평균 발생률로 답했을 때의 점수. 예측이 이보다 낮아야 의미가 있다. */
@Column(name = "BASELINE_BRIER_SCORE", nullable = false)
private double baselineBrierScore;

@Column(name = "ACTUAL_RATE", nullable = false)
private double actualRate;

@Column(name = "CALIBRATION_JSON", nullable = false, columnDefinition = "TEXT")
private String calibrationJson;

@Column(name = "CREATED_AT", nullable = false)
private LocalDateTime createdAt;

/** 예측이 "평균으로 답하기" 보다 나은가. 아니면 화면에 확률을 자랑할 근거가 없다. */
public boolean betterThanBaseline() {
return brierScore < baselineBrierScore;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ List<FacilityCapacitySnapshot> findHistory(@Param("facilityId") Long facilityId,
Optional<LocalDate> findEarliestObservedDate(@Param("facilityId") Long facilityId);

long countByFacilityId(Long facilityId);

/** 백테스트 대상. 관측이 몇 번 이상 쌓인 시설만 검증할 수 있다. */
@Query("SELECT s.facilityId FROM FacilityCapacitySnapshot s "
+ "GROUP BY s.facilityId HAVING COUNT(s) >= :minSnapshots")
List<Long> findFacilityIdsWithAtLeast(@Param("minSnapshots") long minSnapshots);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.carecode.domain.careFacility.repository;

import com.carecode.domain.careFacility.entity.ForecastAccuracy;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface ForecastAccuracyRepository extends JpaRepository<ForecastAccuracy, Long> {

Optional<ForecastAccuracy> findFirstByHorizonMonthsOrderByRunDateDesc(int horizonMonths);

List<ForecastAccuracy> findByRunDateOrderByHorizonMonthsAsc(java.time.LocalDate runDate);

Optional<ForecastAccuracy> findFirstByOrderByRunDateDesc();
}
Loading
Loading