diff --git a/README.md b/README.md index 801cb47..a438ea0 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,225 @@ -# SpendLearning +# 소비학습 -소비 전용 가계부 앱. 지출만 기록하고, 소비 패턴을 학습해 피드백을 제공한다. +소비 전용 가계부 앱. +서버 없이 기기 안에서 온디바이스 학습으로 **소비 패턴을 스스로 학습하고 지출을 예측**합니다. + +## Motivation + +기존 가계부 앱은 수입과 지출을 모두 기록해야 해서 꾸준히 쓰기 번거로웠고, +기록은 쌓여도 그걸 다시 들여다보는 건 귀찮았고, 지난달 지출은 그저 지난달 지출로 끝났습니다. +그래서 **지출 기록만 남기는 대신, 그 기록을 학습에 활용하는 가계부**를 만들기로 했습니다. +서버로 데이터를 보내지 않고 기기 안에서만 학습/예측이 이루어지도록 하는 것을 목표로 삼았습니다. + +## 기능 소개 + +| 소비 기록 | 지출 예측 | 음성으로 기록 | +| :---: | :---: | :---: | +| 날짜를 선택해 금액·카테고리·메모를 기록 | 과거 지출을 통계적으로 분석해 이번 달 예측 | 음성으로 말하면 카테고리까지 자동으로 예측 | +| 소비기록 | 지출예측 | 음성으로기록 | + +- **소비 기록** + - 일반적인 가계부 앱과 비슷합니다. + - 카테고리는 기본 제공 외에 커스텀 추가/수정/삭제가 가능합니다. +- **지출 예측** + - 과거 지출을 학습하는 모델 없이, 통계만으로 패턴을 판단합니다. + - 이번 달 일별/카테고리별로 얼마를 쓸지 미리 보여줍니다. +- **음성으로 기록** + - 말한 내용에서 금액과 카테고리를 온디바이스로 자동 인식합니다. + - 예측된 카테고리를 저장하면 즉시 그 내용으로 재학습되어 다음 예측에 반영됩니다. + +--- + +# Issue Points + +조금 더 자세하게 쓰여있는 노션 링크입니다. + +[📝 노션: 지출 예측, CreateML 대신 통계를 택한 이유](https://app.notion.com/p/CreateML-3a380f28c3ad80ecbad8d6d3f241a47a?source=copy_link) +[📝 노션: 유동적인 카테고리 kNN 온디바이스 학습으로 자동 분류하기](https://app.notion.com/p/kNN-3a380f28c3ad8002a306f6a234e3acb2?source=copy_link) +[📝 노션: 카테고리가 항상 "기타"였던 이유, mlmodelc](https://app.notion.com/p/mlmodelc-3a380f28c3ad802ba63fe78d2af7e8e2?source=copy_link) + +## 1. 지출 예측, CreateML 대신 통계를 택한 이유 + +### 📈 통계를 택하기까지 + +지출 예측은 처음엔 CreateML의 회귀(Regressor) 모델로 접근했습니다. +날짜별/카테고리별 숫자를 예측하는 문제였으니, 전형적인 회귀 문제라고 생각했습니다. + +image + +그런데 조건이 하나 걸렸습니다. +서버 없이 기기 안에서, 사용자 한 명의 지출만으로 계속 재학습되어야 했습니다. +CreateML Tabular Regression이 제공하는 알고리즘은 +[온디바이스 지속 학습이 가능한 모델 타입(신경망, 파이프라인, kNN)](https://apple.github.io/coremltools/docs-guides/source/updatable-model-examples.html)에는 해당하지 않았습니다. + +회귀 모델을 계속 붙잡고 있을 이유가 없다고 판단한 뒤, 예측이 실제로 필요로 하는 게 무엇인지 다시 짚어봤습니다. +예를 들어 매달 15일마다 나가는 구독료처럼 반복되는 지출은, +복잡한 모델 없이도 과거 15일들의 평균과 표준편차만 비교하면 패턴인지 아닌지 판단할 수 있었습니다. +굳이 예측 모델을 학습시키지 않아도, "이 값들이 서로 얼마나 일관된가"만 계산하면 충분했습니다. + +### ✍️ 수식으로 옮기기 + +값이 평균에서 얼마나 떨어져 있는지를 나타내는 게 표준편차($\sigma$)입니다. + +$$\sigma = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(x_i - \mu)^2}$$ + +다만 표준편차는 지출 금액처럼 값의 크기가 다르면 그대로 비교하기 어렵습니다. +매달 10만 원씩 나가는 지출과 매달 1만 원씩 나가는 지출은, +흔들리는 정도가 같아도 표준편차 값 자체는 다르게 나오기 때문입니다. + +그래서 표준편차를 평균($\mu$)으로 나눠, +"평균 대비 상대적으로 얼마나 흔들리는지"로 바꾼 값이 변동계수(CV)입니다. + +$$CV = \frac{\sigma}{\mu}$$ + +변동계수가 0.5 이하면 값들이 충분히 일관되다고 보고 패턴으로 인정했습니다. +0.5보다 크면 평균에서 가장 먼 값부터 하나씩 제외하며 다시 계산합니다. + +> 0.5는 실제 지출 패턴을 가정해 몇 가지 예시로 계산해보고 정한 값입니다. +> 임계값을 0.3처럼 빡빡하게 잡으면 어느 정도 규칙이 있는 소비까지 걸러졌고, +> 0.7 이상으로 느슨하게 잡으면 충동구매나 여행경비처럼 불규칙한 소비까지 패턴으로 오인했습니다. +> 규칙적인 소비는 인정하면서 불규칙한 소비는 걸러내는 경계로 0.5를 택했습니다. + +```swift +private func reliableAverage(of samples: [Int]) -> Int? { + guard samples.count >= minimumSampleCount else { return nil } + + let mean = Double(samples.reduce(0, +)) / Double(samples.count) + let variance = samples.reduce(0.0) { $0 + pow(Double($1) - mean, 2) } / Double(samples.count) + let coefficientOfVariation = variance.squareRoot() / mean + + if coefficientOfVariation <= coefficientOfVariationThreshold { + return Int(mean) + } + + // 이상치 제거 후 재계산 + ... +} +``` + +> 참고: 카테고리별 예측은 이런 신뢰도 판단 없이, 과거 몇 달간 그 카테고리에 쓴 금액의 평균만 냅니다. + +두 로직 모두 별도로 학습된 모델 파일이 없습니다. +코드의 계산식 자체가 결과의 전부이기 때문에, 지출이 기록되는 즉시 다시 계산해 반영할 수 있었습니다. + +--- + +## 2. 유동적인 카테고리, kNN으로 온디바이스 분류하기 + +### 🎙️ kNN을 택하기까지 + +"스타벅스 4500원"라고 말하면 금액과 메모는 물론, 카테고리까지 자동으로 골라주는 걸 목표로 했습니다. +그런데 카테고리를 고르는 분류기를 만들려면, 먼저 "어떤 카테고리들 중에서 고를지"부터 정해져 있어야 했습니다. + +문제는 이 `소비학습` 서비스는 카테고리가 고정되어 있지 않다는 점이었습니다. +사용자가 언제든 새 카테고리를 만들거나 지울 수 있기 때문에, +"카테고리 N개짜리 모델 하나"를 고정해서 만들어 넣는 방식은 쓸 수 없었습니다. + +image + +CreateML Tabular Classification이 제공하는 알고리즘도 살펴봤지만, +모두 학습이 끝나면 하나의 고정된 규칙으로 굳어지는 방식이라 +마찬가지로 [온디바이스 지속 학습이 가능한 모델 타입(신경망, 파이프라인, kNN)](https://apple.github.io/coremltools/docs-guides/source/updatable-model-examples.html)에는 해당하지 않았습니다. + +이 중 kNN은 "카테고리가 몇 개"라고 미리 정해두는 대신, +그동안 쌓인 데이터 중 새 입력과 가장 가까운 데이터 포인트 몇 개를 찾아 다수결로 정하는 방식입니다. +사용자가 카테고리를 새로 만들어도 모델 자체를 다시 설계할 필요가 없어, 방식을 kNN으로 결정했습니다. + +다만 kNN은 CreateML.app이나 CreateML.framework에는 대응하는 타입이 없었습니다. +coremltools 문서의 예제도 Swift가 아니라 Python 코드였고, +그래서 모델을 만드는 도구를 Python coremltools로 사용하였습니다. + +### 🔤 한글을 벡터로 옮기기 + +kNN은 숫자로 된 벡터를 입력으로 받습니다. +메모는 "스타벅스" 같은 한글 텍스트이니, 이걸 어떤 규칙으로 벡터로 바꿀지가 문제였습니다. + +Apple이 제공하는 `NLEmbedding`을 쓰면 될 거라 생각했지만, +확인해보니 한국어를 지원하지 않았습니다. +```swift +let embedding = NLEmbedding.wordEmbedding(for: .korean) +print(embedding) // nil +``` + +가장 단순하게는 글자 하나하나를 세는 방식을 생각했지만, +한글은 완성된 글자의 조합 경우의 수가 많아 vocabulary가 지나치게 커지는 문제가 있었습니다. +> vocabulary란, 벡터의 각 자리(차원)가 어떤 글자를 뜻하는지 정해둔 고정된 사전입니다. + +그래서 한글 글자를 초성/중성/종성으로 분해하는 방식을 택했습니다. +"스"는 초성 "ㅅ"과 중성 "ㅡ"로 쪼개는 식입니다. +자모 단위로 쪼개면 조각의 종류가 훨씬 적고(자음 19개, 모음 21개, 받침 27개), +학습 데이터에 없던 메모라도 vocabulary에 없는 글자를 만날 일이 없습니다. + +```python +# 완성형 한글은 AC00부터 초성*(21*28) + 중성*28 + 종성 순으로 배치되어 있어, +# 시작 코드(AC00)를 뺀 나머지를 588, 28로 나눈 몫/나머지가 각각 초성/중성/종성 인덱스가 된다 +def decompose_hangul(char): + code = ord(char) + offset = code - HANGUL_BASE + cho = offset // (21 * 28) + jung = (offset % (21 * 28)) // 28 + jong = offset % 28 + return (CHOSUNG[cho], JUNGSUNG[jung], JONGSUNG[jong]) +``` + +그런데 벡터화를 해보니 놓친 부분이 하나 있었습니다. +초성 "ㄱ"과 종성 "ㄱ"이 같은 문자로 표현되다 보니, +"역"(받침 ㄱ)과 "가"(첫소리 ㄱ)이 같은 벡터 차원에 섞여 카운트되었습니다. +그래서 종성에는 "_" 접두어를 붙여, 초성과 종성이 서로 다른 토큰으로 구분되도록 했습니다. + +### 🔁 학습시키고, 검증하고, 다시 학습시키기 + +앱을 처음 설치한 시점부터도 어느 정도 예측이 되도록 학습 데이터가 필요했습니다. +기본 카테고리를 바탕으로 500개로 시드 데이터를 만들어 학습시켰고, +시드에 없는 새 메모 55개로 검증해 60%의 정확도를 확인했습니다. + +처음부터 완벽한 정확도가 목표가 아니라, +사용자가 실제로 지출을 기록할 때마다 데이터가 쌓이며 갈수록 정확해지는 구조가 목표였습니다. +그래서 카테고리 예측 결과를 사용자가 확인하고 저장하면, +`MLUpdateTask`로 그 자리에서 즉시 재학습되도록 만들었습니다. --- -## 주요 기능 - -### 소비 기록 -- 달력 화면에서 날짜 선택 후 소비 기록 -- 금액, 카테고리, 메모(선택) 입력 -- 최근 기록 빠른 입력 지원 - -### 통계 -- 카테고리별 비율 (원그래프) -- 이번 달 / 이번 년도 / 저번 년도 전환 가능 -- 이번 달·년도는 직전 기간 대비 증감 표시 - -### AI 피드백 -- 소비 패턴을 학습해 피드백 제공 - - 카테고리 이상 지출 감지 - - 고정 지출 패턴 알림 - - 절약 성공 칭찬 - - 이번 달 소비 예측 -- 메인 화면 상단 카드 + 푸시 알림으로 노출 - -### 토스 캡쳐 자동 기록 -- 토스 거래내역 캡쳐 후 앱에서 불러오면 OCR로 자동 파싱 -- 내용 확인 및 카테고리 선택 후 기록 완료 - -### 카테고리 -- 기본 제공: 식비, 카페/디저트, 교통, 쇼핑, 구독, 의료/건강, 여가/문화, 기타 -- 사용자 커스텀 추가 가능 - -### 기타 -- iCloud 백업/복원 -- 소비 기록 수정/삭제 -- 첫 실행 온보딩 (카테고리 설정, 토스 캡쳐 기능 안내) +## 3. 카테고리가 항상 "기타"였던 이유 + +### 🔍 증상과 원인 추적 + +음성 추가 기능에서 "스타벅스"처럼 시드 데이터에 명확히 있는 상호명을 말해도, +예측 결과가 항상 "기타"(폴백값)로만 나왔습니다. + +폴백값은 모델(`MLModel?`)이 `nil`이거나 벡터화가 실패했을 때만 반환됩니다. +확인해보니 벡터화 결과는 정상이었고, `model`(`MLModel`) 프로퍼티만 `nil`이었습니다. +즉 `init` 시점의 모델 로드 단계에서 이미 실패하고 있었던 것입니다. +```swift +guard let bundledCompiledURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodel") else { + return +} +``` + +### 🗂️ 빌드 산출물을 직접 확인하기 + +Xcode 내비게이터에는 `CategoryClassifier.mlmodel`이 있는데 `nil`이 반환되는게 이상해서, +내비게이터가 아니라 실제 빌드 산출물을 직접 뒤져봤습니다. + +``` +find ~/Library/Developer/Xcode/DerivedData/SpendLearning-*/Build/Products/Debug-iphonesimulator/SpendLearning.app -iname "*CategoryClassifier*" + +# 결과: .../SpendLearning.app/CategoryClassifier.mlmodelc +``` +`.mlmodel`은 안나오고, 확장자가 다른 `CategoryClassifier.mlmodelc`만 들어 있었습니다. +Xcode는 `.mlmodel`을 프로젝트에 추가하면 +빌드 시점에 `.mlmodelc`(컴파일된 모델)로 바꿔 번들에 넣고, 원본은 포함시키지 않는걸로 보였습니다. +코드에서 `withExtension: "mlmodel"`로 찾고 있었으니, 번들 안에 실제로 없는 파일을 찾고 있었던 겁니다. + +### 🔧 수정 + +`withExtension`을 `"mlmodelc"`로 고치고, +컴파일된 모델만 읽는 `MLModel(contentsOf:)`에 맞춰 불필요한 `compileModel(at:)` 호출도 없앴습니다. +```swift +guard let bundledCompiledURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodelc") else { + return +} +try? fileManager.copyItem(at: bundledCompiledURL, to: writableURL) +self.model = try? MLModel(contentsOf: writableURL) +``` + +이제 "스타벅스"를 말하면 "기타"가 아닌 "카페/간식"으로 정확히 예측되는 걸 확인까지 완료했습니다.