機械学習で出庫品名を判別してみた

OPENLOGIの大島です。[OPENLOGI Advent Calendar 2018](https://qiita.com/advent-calendar/2018/openlogi)の17日目です。

弊社は物流プラットフォームを運営しており、日頃からお客様の荷物の発送作業を倉庫様に依頼していますが、特にケアが必要なことの一つに「品名」があります。

品名とは各配送会社の送り状に記載する荷物の概要のようなものです。
荷物の中身が配送上問題ないことを示すことで配送中のトラブルや事故の発生を防ぐ、重要な項目となっています。


記載内容は一般的かつ分かりやすい名称である必要があるのですが、正解が決まっているわけではなく、ここに難しさがあります。そのため、弊社内でも適切な品名で依頼できるよう、様々な施策を実施しています。

そんな品名についてより良いアプローチはないものかと思い、機械学習でいい感じにできないかと試してみたのが今回の内容です。

## 使用するライブラリ
node.jsの自然言語解析ライブラリである`natural` を利用します。
https://github.com/NaturalNode/natural
（node.jsなのは僕が扱えるからという理由です）

このライブラリは各言語の形態素解析、分類器などを含む様々な機能の詰め合わせになっています。
これを利用して今回やりたいことは、既にある`商品名 => 品名` の対応データについて、商品名を形態素に分け（`tokenize`）、正規化（`Stem`）して教師データとします。
教師データを分類器に与えトレーニングさせることで、新しいデータに対して分類が可能となります。

今回は多クラス分類(multi-class classifier)といってデータを複数のクラス（ラベル）のうち1つに分類する方法を用います。またアルゴリズムはナイーブベイズ分類というものを利用します。

ここで出てきた単語については自分も正直分からないものが多かったので、簡単に見ていきます。

### Tokenize
形態素解析です。形態素とは意味をもつ表現要素の最小単位で、文章を形態素に分解します。

例えば`キッチン用アルコールスプレー`は`キッチン` `用` `アルコール` `スプレー`のように分解されるはずです。

本ライブラリでは下記を利用しているようです
http://chasen.org/~taku/software/TinySegmenter/

### Stem
単語から不要な形態素を取り除くことです。

日本語の場合は4文字以上のカタカナの単語に対して伸ばし棒をカットするだけのようです
例えば「コンピューター」は「コンピュータ」となります

英語の場合は単語なら例えば `words` -> `word` と単数形になるとか・・

### Naive Bayes classifier(単純ベイズ分類器)
ベイズの定理を用いた教師ありの分類器（データを分類するシステム）で、難易度は低いそうです。
教師ありとはあらかじめ例題と正解、不正解の結果を与えることで学習させておくモデルのことです。

こちらの記事が大変分かりやすいです
[機械学習ナイーブベイズ分類器のアルゴリズムを理解したのでメモ。そしてPythonで書いてみた。](https://qiita.com/ishizakiiii/items/07cc7e463dceb3efe1a1)

## やってみた
コード的には下記のような感じです。

### 分類器の生成（トレーニング）
`品名:商品名`の対応をcsvから読み込み、分類器をトレーニングします。csvでは商品名、品名の対応を数万件記載しています。
分類器はjsonとして出力されます。


```javascript
import { BayesClassifier, Tokenizer } from 'natural';
const natural = require('natural');

function generateClassifierJson() {
  const csvPath = 'label_name_map.csv';
  const trainData = parseCsv(csvPath);

  const stemmer = new natural.StemmerJa;
  const classifier = new natural.BayesClassifier(stemmer);

  trainData.forEach(map => {
    classifier.addDocument(map.name, map.label); // 商品名 => 品名の対応を追加
  });
  classifier.train(); // 読み込み

  const savePath = 'classifier.json';
  classifier.save(savePath, function(err, classifier) {
    console.log(`csv generated => ${savePath}`);
  });
}
```

教師データを含んだ`classifier.json` が生成されます

### 分類
分類器をjsonからパースし、そいつにcsvから商品名を入力します

```js
import * as _ from 'lodash';
const natural = require('natural');

async function classify() {
  const classifierPath = './classifier.json';
  const stemmer = new natural.StemmerJa;

  const target = parseCsv('./itemNames.csv');
  const targetNames: = _.map(target, 'name');

  const classifier = natural.BayesClassifier;

  classifier.load(classifierPath, stemmer, function(err, classifier) {
    console.log('-----------------------------');
    targetNames.forEach(name => {
      const result = classifier.classify(name);
      console.log(`name:  ${name}`);
      console.log(`label: ${result}`);
      console.log('-----------------------------');
    });
  });
}
```
classifier.jsonを読み込んで`classifier.classify(name)`するだけです

ライブラリがいい感じにやってくれるのでこれだけです

## 実行
Amazonの商品を適当にピックアップしたものをリスト化して分類器に渡します（今回はcsvで）

```
"name"
"イヤホン 高音質 イヤフォン ハイレゾ 重低音 有線 スポーツイヤホン 遮音 マイク付き カナル型 リモコン付き 携帯 スマホ PCに対応 イヤフォン（ホワイト）"
"つめきり ステンレス製 爪切り 手足はがね 握りやすい スパット切れる (ブラック)"
"フルエタニティリング 指輪 アレルギーフリー ステンレス SS316L ペアリング 男女兼用 （17号）"
"ランニングシューズ  M (旧モデル) メンズ"
```

分類の結果は下記の通りです。（`name`が商品名で `label` が品名の判定結果）

```
-----------------------------
name:  イヤホン 高音質 イヤフォン ハイレゾ 重低音 有線 スポーツイヤホン 遮音 マイク付き カナル型 リモコン付き 携帯 スマホ PCに対応 イヤフォン（ホワイト）
label: 色鉛筆
-----------------------------
name:  つめきり ステンレス製 爪切り 手足はがね 握りやすい スパット切れる (ブラック)
label: 姿勢矯正ベルト
-----------------------------
name:  フルエタニティリング 指輪 アレルギーフリー ステンレス SS316L ペアリング 男女兼用 （17号）
label: ノートパソコン
-----------------------------
name:  ランニングシューズ  M (旧モデル) メンズ
label: 除雪機    <- !?
-----------------------------
```

なるほど。ランニングシューズは除雪機のようです。これは心が折れそうです。

## 改善
例えば上記結果で一番ひどかった「ランニングシューズ」→「除雪機」の対応について見てみます。
品名「除雪機」の元となった教師データはこんな感じで区切られていました

```
商品名 : PowerGライト付き電動除雪機スノーブロワー 20m延長コード付き
解析結果 : ["ライト","付き","電動","除雪","機","スノーブロワ","2","0","m","延長","コード","付き"]
```

少なくとも「付き」とか「2」とかはこれ単体では品名に関係する情報を持たないようなきがしますね。

というわけで教師データを入力する前に不要そうな文字列をフィルターしてから解析器に渡してみます。
不要な文字列はひとまず、数字、ひらがな1文字や記号などということにします。

なお、先ほどのコードでは商品名（文字列）を突っ込むだけでいい感じに解析結果をtrainしてくれたのですが、`classifier.addDocument()`に自前で解析した形態素を渡すことでも同様に学習が可能です。

というわけでtokenizeした結果にフィルター処理を噛ませてみたのを与えてみます

フィルター処理は雑ですがこんな感じ

```typescript
export default function filter(words) {
  const uniqWords = _.uniq(words);
  return uniqWords
    .filter(word => word.match(/[^\d]/))
    .filter(word => word.match(/[^A-z|ぁ-ヴ]/))
    .filter(word => word.match(/[^【|】|※|「|」|『|』|-]/)) // 記号一文字
    ;
}
```

これで分類した結果が下記です

```
-----------------------------
name:  イヤホン 高音質 イヤフォン ハイレゾ 重低音 有線 スポーツイヤホン 遮音 マイク付き カナル型 リモコン付き 携帯 スマホ PCに対応 イヤフォン（ホワイト）
label: カラオケ機器
-----------------------------
name:  つめきり ステンレス製 爪切り 手足はがね 握りやすい スパット切れる (ブラック)
label: チラシケース
-----------------------------
name:  フルエタニティリング 指輪 アレルギーフリー ステンレス SS316L ペアリング 男女兼用 （17号）
label: 栓抜き指輪
-----------------------------
name:  ランニングシューズ M (旧モデル) メンズ
label: ブーツ
-----------------------------
```

ランニングシューズ は ブーツになりました！
除雪機よりは近づいたのではないでしょうか

他もまぁ違うけど何となく近づいたのでないでしょうか？うん、近づきましたね（強引）

## 終わりに
この辺で力尽きてしまいましたが、ちゃんとやるならベイズ分類のアルゴリズムに基づいた改善が必要そうです

ベイズ分類のアルゴリズムに関する[こちらの記事](https://qiita.com/ishizakiiii/items/07cc7e463dceb3efe1a1)(再掲)によると、
例えば判定したい文字列中の単語がとある学習データ側に存在しない場合、そのデータが選ばれる確率が0もしくは大幅に下がるようです。
そのため判定したい文字列側も極力意味のある単語のみ入力したりする必要がありそうです

また、他にも分類器の都合で英字に対応していなかったり、形態素が少ない商品の精度が特に低いなどなど課題は山積みですが、今後の検討課題とさせていただきます:pray:

お読みいただきありがとうございました！