Elasticsearch で Learning-to-Rank を試してみた！


この記事は、[ただの集団 AdventCalendar 2019](https://adventar.org/calendars/4054)の21日目の記事です。

## はじめに

担当日前日に「Elasticsearch で Learning-to-rank やりたいので、環境構築の手順とその使い方についてまとめてね。ヨロピコ！」と振られたので、今回は Elasticsearch with learning-to-rank の構築手順とその使い方を紹介します。

今回作成したものは[コチラ](https://github.com/wararaki718/sample-learning-to-rank)

## Learning-to-rank とは

検索エンジンにおける learning-to-rank とは、機械学習と検索するデータを使って、検索結果のランキングの順序を改善する手法のことです。順序学習やランキング学習とも呼ばれています。

今回は、Elasticsearch の [learning-to-rank のプラグイン](https://github.com/o19s/elasticsearch-learning-to-rank) を使います。learning-to-rank のレポジトリにある demo を使って、ランキング改善を体験してみたいと思います。

## 環境構築

demo を試すために、事前に以下の環境構築をします。

- Java の実行環境
- Python3 の実行環境
- Elasticsearch に learning-to-rank プラグインを導入
  - https://github.com/elastic/elasticsearch
  - https://github.com/o19s/elasticsearch-learning-to-rank

プラグイン入り Elasticsearch の構築は、docker image を使うと楽です。

- https://hub.docker.com/_/elasticsearch

以下、Elasticsearch with Learning-to-Rank docker image のサンプル

```Dockerfile
FROM elasticsearch:7.4.1

RUN bin/elasticsearch-plugin install -b http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.4.1.zip
```

環境構築後、Learning-to-Rank のレポジトリをクローンして demo ディレクトリに移動しましょう。

```shell
$ git clone https://github.com/o19s/elasticsearch-learning-to-rank.git
$ cd elasticsearch-learning-to-rank/demo
```

demo ディレクトリへ移動後、順番にスクリプトを実行して Learning-to-Rank を体験しましょう！

## データとライブラリの準備

prepare.py を実行して、検索データと学習モデルを作成するライブラリ（Ranklib）をダウンロードします。

```shell
$ python prepare.py
```

実行すると、映画のデータ（tmdb.json）とランキング学習のライブラリ（RankLibPlus-0.1.0.jar）をダウンロードします。（tmdb.jsonはサイズが大きいのでダウンロードに時間がかかるので注意！）
ダウンロード完了後、Elasticsearch の環境を整えます。

## Elasticsearch の環境を整える

Learning-to-Rank のプラグインを導入した Elasticsearch を立ち上げ、データを入れます。Elasticsearch 起動後、index_ml_tmdb.py を実行してインデックスの設定とデータの挿入を行います。以下のスクリプトを実行すると、tmdb.json を Elasticsearch にインサートします。

```shell
$ python index_ml_tmdb.py
```

インデックスの設定とデータの挿入が完了したら、次は学習に使う feature 変換のクエリを設定します。以下のスクリプトを実行すると、学習に使う field を設定します。（設定されるフィールドは demo/1.json と demo/2.json 参照。demo では、title と overview の検索スコアを feature として設定している。）

```shell
$ python load_features.py
```

データを feature に変換する field の準備ができたら、モデル作成を行います。

## Elasticsearch にモデルをデプロイする

train.py を実行してモデルを作成して、Elasticsearch にデプロイします。

```shell
$ python train.py
```

train.py では、以下の処理が実行されます。

- 前処理
    - sample_judgements.txt をパースして、ranklib format に変換する。
- モデル生成
    - Ranklib を実行して、モデル作成を行います。
- モデルのデプロイ
    - Easticsearch にモデルを導入する。

### 前処理

demo では、[sample_judgements.txt](https://github.com/o19s/elasticsearch-learning-to-rank/blob/master/demo/sample_judgments.txt) を学習して、検索結果のランキングを改善します。
sample_judgements.txt は、検索クエリ（qid）に対する検索結果（#7555 Rambo, #1370 Rambo III, ...）を表しており、検索クエリと検索結果のペアごとに評価値（grade）を設定しています。demo では、以下3種のクエリがあります。

```text
# qid:1: rambo
# qid:2: rocky
# qid:3: bullwinkle
```

各クエリの検索結果に対して、grade を設定します。(数字が大きいほど良い。)

```text
# grade (0-4)	queryid	docId	title
4	qid:1 #	7555	Rambo
3	qid:1 #	1370	Rambo III
3	qid:1 #	1369	Rambo: First Blood Part II
3	qid:1 #	1368	First Blood
0	qid:1 #	136278	Blood
0	qid:1 #	102947	First Daughter
0	qid:1 #	13969	First Daughter
0	qid:1 #	61645	First Love
0	qid:1 #	14423	First Sunday
0	qid:1 #	54156	First Desires
4	qid:2 #	1366	Rocky
3	qid:2 #	1246	Rocky Balboa
3	qid:2 #	60375	Rocky VI
3	qid:2 #	1371	Rocky III
3	qid:2 #	1375	Rocky V
3	qid:2 #	1374	Rocky IV
0	qid:2 #	110123	Incredible Rocky Mountain Race
0	qid:2 #	17711	The Adventures of Rocky & Bullwinkle
0	qid:2 #	36685	The Rocky Horror Picture Show
4	qid:3 #	17711	The Adventures of Rocky & Bullwinkle
0	qid:3 #	1246	Rocky Balboa
0	qid:3 #	60375	Rocky VI
0	qid:3 #	1371	Rocky III
0	qid:3 #	1375	Rocky V
0	qid:3 #	1374	Rocky IV
```

このデータを [Ranklib Format](https://sourceforge.net/p/lemur/wiki/RankLib%20File%20Format/) に変換すると、以下のようなデータになります。

```text
4       qid:1   1:12.318474     2:10.573917 # 7555      rambo
3       qid:1   1:10.357875     2:11.950391 # 1370      rambo
3       qid:1   1:7.010513      2:11.220095 # 1369      rambo
3       qid:1   1:0.0   2:11.220095 # 1368      rambo
0       qid:1   1:0.0   2:0.0 # 136278  rambo
0       qid:1   1:0.0   2:0.0 # 102947  rambo
0       qid:1   1:0.0   2:0.0 # 13969   rambo
0       qid:1   1:0.0   2:0.0 # 61645   rambo
0       qid:1   1:0.0   2:0.0 # 14423   rambo
0       qid:1   1:0.0   2:0.0 # 54156   rambo
4       qid:2   1:10.686391     2:8.814846 # 1366       rocky
3       qid:2   1:8.985554      2:9.984511 # 1246       rocky
3       qid:2   1:8.985554      2:8.067703 # 60375      rocky
3       qid:2   1:8.985554      2:5.660549 # 1371       rocky
3       qid:2   1:8.985554      2:7.300772 # 1375       rocky
3       qid:2   1:8.985554      2:8.814846 # 1374       rocky
0       qid:2   1:6.815921      2:0.0 # 110123  rocky
0       qid:2   1:6.0816855     2:8.725066 # 17711      rocky
0       qid:2   1:6.0816855     2:5.9764795 # 36685     rocky
4       qid:3   1:7.6720834     2:12.722421 # 17711     bullwinkle
0       qid:3   1:0.0   2:0.0 # 1246    bullwinkle
0       qid:3   1:0.0   2:0.0 # 60375   bullwinkle
0       qid:3   1:0.0   2:0.0 # 1371    bullwinkle
0       qid:3   1:0.0   2:0.0 # 1375    bullwinkle
0       qid:3   1:0.0   2:0.0 # 1374    bullwinkle
```

このデータを使ってモデル作成を行います。

### モデル生成

モデルを作成する。demo では、以下のモデルが生成されます。

- MART
- RankNet
- RankBoost
- AdaRank
- coord Ascent
- LambdaMART
- ListNET
- Random Forest
- Linear Regression

（各モデルの詳細について、今回は割愛します。）

### モデルのアップロード

Elasticsearch にモデルをアップロードする。Post リクエストで生成したモデルをアップロードする。以下サンプル。

```shell
POST _ltr/_featureset/movie_features/_createmodel
{
    "model": {
        "name": "test_9",
        "model": {
            "type": "model/ranklib",
            "definition": "## Linear Regression\n## Lambda = 1.0E-10\n0:0.2943936467995844 1:0.2943936467995844 2:0.12167703031808977"
        }
    }
}
```

アップロードしたモデルを使って検索するときは、model.name を指定します。

## 検索する

実際に検索して、Learning-to-Rank による検索結果の改善を体験してみましょう！search.py を実行すると、以下の検索結果が得られます。

```shell
$ python search.py Rambo
{"query": {"multi_match": {"query": "Rambo", "fields": ["title", "overview"]}}, "rescore": {"query": {"rescore_query": {"sltr": {"params": {"keywords": "Rambo"}, "model": "test_6"}}}}}
Rambo
Rambo III
Rambo: First Blood Part II
First Blood
In the Line of Duty: The F.B.I. Murders
Son of Rambow
Spud 
$
```

わかりづらいので、Learning-to-Rank あり/なしを比較した結果を用意しました。それぞれ以下の結果が得られます。

```
## search with learning-to-rank
1 Rambo
2 Rambo III
3 Rambo: First Blood Part II
4 First Blood
5 In the Line of Duty: The F.B.I. Murders
6 Son of Rambow
7 Spud

## search without learning-to-rank
1 Rambo
2 Rambo III
3 First Blood
4 Rambo: First Blood Part II
5 In the Line of Duty: The F.B.I. Murders
6 Son of Rambow
7 Spud
```

学習結果が反映されてますね！！

## まとめ

今回は、Elasticsearch で Learning-to-rank を試す手順の紹介と、Learning-to-Rank を検索エンジンに反映までやりました。今回使ったライブラリをお手軽に試せるように[レポジトリ](https://github.com/wararaki718/sample-learning-to-rank)にまとめましたので、使っていただければ幸いです。
また、この記事では実行手順を紹介しましたが、機会があれば Learning-to-Rank の各ロジックや今回使ったライブラリの仕組みの詳細を紹介したいです。