家計簿の出費項目にカテゴリを付けるのを自動化する

皆さん、家計簿つけてますか？僕は何度もつけようと試みたことはあるのですが、その度に諦めて来た口です。
諦めた原因というのは一概に **面倒くさい** からなんですよね。
同じ理由で諦めた方も多いのでは無いかと思います。人類の中で僕だけ怠惰ということでないよう、ぜひそうあってほしいです。

ただ、怠惰はプログラマの [三大美徳と言われています](https://ja.wikipedia.org/wiki/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9E#.E3.83.97.E3.83.AD.E3.82.B0.E3.83.A9.E3.83.9E.E3.81.AE.E4.B8.89.E5.A4.A7.E7.BE.8E.E5.BE.B3)ので、毛頭改善する気はないです。

そこで、何が一番面倒くさいのかを考えたところ、費用のカテゴライズなのかなと思っています。
僕は家賃を除く月々の出費の 9割ほどがクレジットカード払いなので、どこどこに対していくら払ったかというのは比較的簡単にわかります。
それで、家計簿とかで良くある円グラフとか線グラフとかを可視化したいのですが、ここでカテゴライズされていないことが大きな問題になります。

そこで本記事の目的は、支払先と支払金額を元にして、その出費のカテゴライズを行うことです。

## データについて

僕個人の1年+少し分のカード支払い履歴 700件を教師データとして使います。
テストデータには、同様に僕個人のカード支払い履歴 96件を用います。
当たり前ですが、700件の中にテストデータ 96件は含まれていません。
これは通常の分類問題に比べて明らかに少量のデータであるため、アルゴリズムによる差異はあまり出ないかもしれません。

## Excuse

ソースコードもデータも公開しようかと思って書き始めたんですが、あっ、データのマスキングめんどくさい！ということに気づき、公開を取りやめました。
やはりカードの使用履歴というのは個人を特定する上で非常に強力な情報なので、簡単に公開するのは避けたいなと。
というわけで、生活感とかがバレない程度で、かつ結果が分かる程度にちょいちょい入れていきます。

それから、この投稿自体は、「手軽にできた」というところに注力しているため、厳密性やアルゴリズムの説明を大幅に欠いています。
また、Recall も Precision も F値も出てきません。
次回、じゃあ精度をあげるためには？というところについて書くつもりです（時間があれば）。


## 技術の話

データ形式は例えば以下のような形になります（csv形式）。

```
利用日,利用店名,利用金額,支払区分,今回回数,支払い金額,その他（換算レートなど）
17/05/25,ＥＴＣ　特割　関東支社,800,１,１,800,自木更津第一 至木更津第一 普通車
```

ここにカテゴリ名が入っていてくれると話はここで終わりなのですが、なかなかそうは行きません。
この問題は、以下のように定式化できます。

### 問題の定式化

* INPUT: 利用日、利用店名、利用金額、支払区分、その他
* OUTPUT: その出費項目に関するカテゴリの確率分布（つまりその出費項目が属するであろうカテゴリ）

### カテゴリデータの作成

さすがに分類先のカテゴリ一覧まで自動生成させるのは難しそうなので、有りものを転用させてもらうことにします。
Google で「家計 カテゴリ 出費」で検索したら（僕の検索順位で）一番上に出てきた『家計簿っち』さんから拝借します。

家計簿の項目(費目)一覧 - 家計簿っち
http://kakeibo.lucky-days.jp/interview/how-to-classify/guidance/

その他には、統計局のホームページなども見たのですが、こちらの分類はちょっと細かすぎて扱いにくいので、棄却されました。

統計局ホームページ/家計調査 収支項目分類一覧（平成19年1月改定）
http://www.stat.go.jp/data/kakei/koumoku/bunrui.htm

具体的には、以下のようなデータ（抄録です）が入っています。

```
カテゴリID,カテゴリ名,親カテゴリID
1,食費,-1
2,食費,1
3,お菓子・ドリンク,1
4,水道光熱費,-1
5,上下水道,4
6,電気,4
7,ガス,4
8,通信費,-1
9,携帯,8
...
```

### 教師データの作成

さて、最も面倒くさいフェーズです。
データ一件一件に対して、上のカテゴリを割り当てて行きます。

ですがその前に、まずはダウンロードしたデータを綺麗に（クレンジング）します。
これはデータ形式によるので一概にこうしろ！という方法はないのですが、多くの場合必要になるのは以下のような作業かと思います。

* 文字コードの変更（utf8 に変換）
* ラベル行など、不要な行の削除
* 表記揺れの補正（全角 -> 半角 など）

今回は検証なので、使用言語を制限せずに、一番僕にとって楽な言語で書いていきます。

#### 文字コードの変更（utf8 に変換）

僕の観測した限りだと、csv は Shift-JIS で提供されていることが多いです。
UTF-8 だと Excel で開けないので、Excel との互換性を持たせるためにそうなっているのかと予想されますが、
MacOS や Linux で扱う上では面倒以外の何物でもないので、UTF-8 に変換してしまいます。
[nkf](http://itpro.nikkeibp.co.jp/article/COLUMN/20060227/230849/?rt=nocnt)コマンドを使います。

```bash
# 文字コードの確認
$ nkf -g expenses.csv
Shift_JIS

# Shift-JIS -> UTF-8変換
$ nkf -w expenses.csv > utf8_expenses.csv

# 文字コードの確認
$ nkf -g utf8_expenses.csv
UTF-8
```

#### ラベル行など、不要な行の削除

これは入力ファイルによってどういう処理が必要か異なるので、一概に言えないのですが、
だいたい bash とかで `sed`、`head`、`tail`、`awk`、`(e)grep`、`cut` とかを使うことが多いです。
どうしてもクレンジング仕切れないやつは python やら ruby やらで書きます。

#### 表記揺れの補正（全角 -> 半角 など）

例えば同じ単語を指すのに違う文字として認識されるものとして、「Ｇｏｏｇｌｅ」と「Google」と「google」みたいなものがありえます。
全く別物のこともあるのですが、そうでないことの方が多いので、全角英数字は全て半角英数字で置き換えます。
ここでは ruby を使っています。

```ruby
#!/bin/ruby
#-*- coding:utf-8 -*

f = open('./utf8_expenses.csv')
rows = f.read.split("\n")
f.close

rows.each do |row|
  puts(row.tr('０-９ａ-ｚＡ-Ｚ', '0-9a-zA-Z'))
end
```

#### カテゴリの割り当て

クレンジングが済んだらカテゴリを割り当てて行きます。
ここで「カテゴリ名」を入れてしまうと後々もう１ステップ必要になるので、代わりに「カテゴリID」を入れるようにします。
どうやるかというと、一行ずつ目で見て判別していきます。
苦行です。
苦行ですが、後に自動化が待っているので、頑張ります。

前処理の終わったデータは、以下のように変形しています（こちらも抄録）。

```csv
カテゴリID,利用日,利用店名,利用金額,支払区分,今回回数,支払い金額,その他（換算レートなど）
42,2016/11/30,Amazon Prime Now,4030,1,1,4030,,,
2,2016/12/2,ローソン,806,1,1,806,,,
2,2016/12/2,ローソン,973,1,1,973,,,
2,2016/12/2,UBER BV (866-576-1039 ),1440,1,1,1440,1440.00 JPY 1.0000 12 03,,
45,2016/12/3,GOOGLE*SVCSAPPS 00A22F (CC GOOGLE.COM),691,1,1,691,5.98 USD 115.634 12 05,,
24,2016/12/7,JR東日本みどりの窓口（びゅうプラザ）,10890,1,1,10890,,,
21,2016/12/9,リッチモンドホテルプレミア 仙台駅前,10000,1,1,10000,,,
...
```

### 学習モデルの検討

次に、学習モデルを考えます。なかなか機械学習部分のプログラミングには進みません。
今回の問題は、ベクトル値で表現されるアイテムをクラス分けしていく、[他クラス分類（Multi Classification）](https://en.wikipedia.org/wiki/Multiclass_classification)という問題です。
これは実に多種多様な分野で研究されている問題で、アルゴリズムも星の数ほどあります。
代表的なアルゴリズムだと、ナイーブベイズ（Naive Bayes）、決定木（Decision Trees）、SVM（Support Vector Machine）、k近傍探索（k-NN: k-nearest neighbors）、
それから最近話題のニューラルネットワーク（Neural Networks）によるアプローチ（深層学習含む）などがあります。
正直な話、この問題に関しては実用上は DNN によるアプローチ以外大差ないと思っているので（DNN は前処理を施さなくて良いかも知れないという点でアドバンテージがあるかな、でもそんなにデータないから精度出るか怪しいな、実際無理だろうな、あとお金かかるな）、今回は Random Forest というアルゴリズムを用います。
どんなに良いアルゴリズムを持ってきて、精度が 90% から 95% になったとしても、
間違ったカテゴリに分類されているものが 10/100件から 5/100件になるだけです。
今回の用途で、複雑なアルゴリズムを採用してまでその 5% の改善が必要か？と言われると、そんなことはないので、使いたいアルゴリズムを選びました。


#### 補足：Random Forest

Random Forest は、2001年に Leo Breiman によって[提案された](https://link.springer.com/content/pdf/10.1023%2FA%3A1010933404324.pdf)アルゴリズムです。
元々のアイデアは、いくつかのランダムな入力ベクトルを使って決定木を使って分類問題を解いて、その結果を合わせたらもっと精度あがるじゃん！というものです（正確には原論文などを読んでください）。
精度検証のタイミングでこのアルゴリズムを詳しく知っていないといけなくなるのですが、それは次回に。

---

さて、学習データをじっと見つめると、どうやら支払先と請求金額とで概ねカテゴリが同定できるのでは？というアイデアが浮かびます。
今回はそのアイデアを信じることにして、「支払先」をどう扱うかを考えます。
上記の例には載っていないのですが、「ローソン◯◯店」といった支払先名もあり、そういったものは「ローソン」として扱ってほしいです。
なぜなら、ローソン代官山店で買った牛乳とローソン品川店で買った牛乳とは、どちらも牛乳であってどちらも飲食費だからです。
そうすると同じお店をただの夕食として使った場合（食費）と、接待で使った場合（交際費・接待費）とで判別ができなくなりますが、それはデータにないのでこれ以上どうしようもないです。

基本的に日本語を扱う場合は、形態素解析、n-gram などの処理を施して tokenize して、それを基にベクトル化（vectorize）します。
英語の場合は単語がスペースなどで区切られているので、tokenize はあまり必要ありません。
この意味では、日本語の方が難しい問題だといえます。

まとめると、支払先を tokenize して vectorize したものと、支払金額を入力として、カテゴリID を推測する、というモデルになります。

### 学習モデルの実装

今回は基本的に python + scikit-learn で実装します。
また、analyzer に [janome](https://github.com/mocobeta/janome)、vectorizer に [TF-IDF vectorizer](http://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html) を使います。
janome を使う理由は pip でインストールできるからです。mecab でもなんでも良いのですが、パッケージ管理から解放されたい意図があります。
TF-IDF vectorizer を使う理由はあまりありません。おそらく支払い先は名詞が多いので、そのまま bin でカウントしても良いんですが、気分です。
チューニングする際に変えても良いかもしれません。

各パッケージのインストールは色々なドキュメントがあるので、そちらを参考にしてください。
昔は scikit-learn のインストールが凄く大変だった気がしますが、いつの間にか pip で問題なく入るようになっていて文明の発展を感じました。

#### インポート

特筆すべきところはないのですが、これらのパッケージを使います。

```python
import csv
import numpy as np
from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.ensemble import RandomForestClassifier
```

#### Tokenize / Vectorize

まずは vectorizer に渡す analyzer を定義します。
単純に tokenizer を渡しても良いのですが、助詞とか記号とかが邪魔なので、
名詞と固有名詞以外は以外は排除するような analyzer を書きます。


```python
# 残す形態素
COLLECTABLES = [
    '名詞',
    '固有名詞'
]

def analyzer(text):
    # 与えられたテキストを tokenize する
    t = Tokenizer()
    tokens = t.tokenize(text)

    # 重複を削除するために、一度 dict に突っ込んでから list に変換
    words_dict = {}
    for token in tokens:
        # janome がカンマ区切りで解析結果を渡してくるので切り離して名詞と固有名詞だけ残す
        if token.part_of_speech.split(',')[0] in COLLECTABLES:
            words_dict[token.surface] = True

    # リストに変換
    return list(words_dict.keys())
```

次に、定義した analyzer を使って vectorizer を定義します。

```python
# vectorizer を定義
# min/max_df はパラメータなので、精度が出なければ調整する
vectorizer = TfidfVectorizer(analyzer=analyzer, min_df=1, max_df=40)

# raw_data には [{'支払先': 'Amazon Prime Now', '支払金額': 10800}, ...]
# という形式のデータが入っているとする
# ここから支払先だけとりだして、リストにする
corpus = [x['支払先'] for x in raw_data]

# 支払先のリストをベクトル化する
# sparse matrix で返ってくるが、後で支払金額とくっつけて 1つの特徴ベクトルにするので、
# array型に変換しておく
vec_for_shop = vectorizer.fit_transform(corpus).toarray()
```

支払先のベクトル化が終わったら、支払金額とまとめて教師データを作成する。

```python
x = []
y = []
for d, v in zip(raw_data, vec_for_shop):
    # 入力ベクトルは、先程ベクトル化した支払先と、支払金額
    e = list(v) + [float(d['支払金額'])]
    x.append(e)

    # y にはカテゴリID を詰め込む
    y.append(int(d['カテゴリID']))
```

教師データができたら、やっと学習に入ります。
実はここが一番簡単です。

```python
forest = RandomForestClassifier(n_estimators=100, random_state=1)
forest.fit(x, y)
```

`n_estimators` と `random_state` はパラメータなので（以下略）。

さて、これで forest の学習が完了しました。
試しに別の月のデータを持ってきて、カテゴリを予測してみます。

```python
# 教師データと同様の変換
# 本当はメソッド化して再利用したい
test_data = [x['支払先'] for x in raw_test_data]
test_data_payment = vectorizer.transform(test_data).toarray()
fv_test = []
for d, v in zip(raw_test_data, test_data_payment):
    e = list(v) + [float(d['支払金額'])]
    fv_test.append(e)

# 学習済みの forest を使ってカテゴリを予測
prediction = forest.predict(fv_test)
```

これを実行すると、`prediction`　に予測結果が入ります。
より詳細なクラスの使い方については、[sklearn.ensemble.RandomForestClassifier](http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) をご覧ください。


### 結果の確認

#### 上手く行ったところ

抄録です。
体感値で 90% くらい正しい分類がされています。

```
書籍 {'支払先': '代官山  蔦屋書店', '支払金額': '6600'}
レンタカー {'支払先': 'タイムズカープラス', '支払金額': '2364'}
食費 {'支払先': 'ファミリーマート', '支払金額': '633'}
食費 {'支払先': 'セブン−イレブン・ジャパン', '支払金額': '489'}
理容院・美容院 {'支払先': 'ラクテンペイ  ＊＊＊（美容室名）', '支払金額': '4860'}
電気 {'支払先': '東京電力  電気料金等', '支払金額': '4315'}
レンタカー {'支払先': 'タイムズカープラス', '支払金額': '9362'}
レジャー {'支払先': 'Bリーグオンライン', '支払金額': '6216'}
その他交通費 {'支払先': 'ETC  関西支社', '支払金額': '210'}
サーバー代等 {'支払先': 'GOOGLE＊SVCSAPPS  00A2', '支払金額': '354'}
```

#### 上手く行かなかったところ

こちらも抄録です。
ETC支払いにも関わらず、食費としてカテゴライズされています。
また、ガソリンスタンドやホテル、比較的高額な食事（8,000円くらい）も誤って分類されていました。
実はこの月は西日本に旅行に行ったため、普段あがらない支払先がいくつか入っています。
基本的にはテキストベースのロジックなので、登録のないものは金額だけで判断して、食費や書籍に割り当てられているのだと思います。
僕は職業柄専門書をまとめて買うことが多いため、低額（<3,000円）なものは食費、高額（>=3,000円）なものは書籍として判別されているのだと予想されます。

```
食費 {'支払先': 'ETC本四  岡山管理センター', '支払金額': '2860'}
食費 {'支払先': 'ETC本四  岡山管理センター', '支払金額': '2270'}
食費 {'支払先': 'イデミツコウサン', '支払金額': '2695'}
書籍 {'支払先': '伊藤忠エネクス  ENEOS', '支払金額': '3899'}
書籍 {'支払先': 'プラザホテル下関', '支払金額': '4200'}
```

過去にデータがない以上仕方がない部分が多いですが、それでも「ETC本四  岡山管理センター」あたりは「その他交通費」として分類してほしかったところです。
他の ETC は正しく「その他交通費」となっているため、支払先による寄与と支払金額による寄与とが均衡していて、結果金額の方が優先されたということでしょうか。

まぁただ、84/96件は期待通りに分類されていたため、見知らぬ土地を旅行した上でという文脈も考えると上々かと思います。


### 考察

今回、上手く行かなかったところのその要因は、多くが「過去のデータになかった」という点かと思われます。
支払先が登録されていないというのはある意味で仕方なく、しかしそれでも精度を高めようと思ったら

* 世界中を回って色々な支払いをして、知らないデータを無くす
* 別の特徴量を導入する

という方法で回避することができるのかと思います。
今回はおよそ 700件の支払いデータを教師データとしているため、アルゴリズムによる差異はほとんど無視できると考えていますが、
もし 10万件やそれ以上の学習データが使えるとなると、アルゴリズムの差異による収束性、精度の違いなどが利いてくると思います。


### まとめ

1年間の自分のカード履歴を使うと、そこそこ良い精度で出費のカテゴリ分類をしてくれる classifier が作れます。
より精度を出すためには、データを増やすか、特徴量を増やすかして、
その後にアルゴリズムを検討するという方針が良いような気がします。