Elasticsearch Learning to Rankプラグインを試す

# はじめに

[Elasticsearch Learning to Rank](https://github.com/o19s/elasticsearch-learning-to-rank)プラグインという言葉はよく耳にするのですが、内容を理解できていないところがありました。
今回はプラグインを利用してインデックスデータを準備するところから検索するところまでの一連の流れを試すべく参考記事を見つつ動かしてみました。
また、参考Qiita記事の各種バージョンが古いこともあり、今回は記載されている手順の各種バージョンを上げています。

# 参考リンク

デモコード関連のリンクは以下。

https://qiita.com/wararaki/items/3c2e946dabc7d3794906

* GitHub
  * [sample-learning-to-rank](https://github.com/wararaki718/sample-learning-to-rank)
  * [elasticsearch-learning-to-rank/demo](https://github.com/o19s/elasticsearch-learning-to-rank/tree/126e53d0cc75c087afe3b3aba30f817942cd1290/demo)


Elasticsearch LTRに関する詳細を知るために読んだほうが良い記事は以下。

https://techblog.zozo.com/entry/elasticsearch-learning-to-rank-plugin

https://techblog.zozo.com/entry/basics-of-elasticsearch-ltr-plugin-and-feature-score-cache


また、公式ドキュメントは以下。

* https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/


# Elasticsearch Learning to Rank (Elasticsearch LTR)について

ElasticsearchでLTRを使えるようにしたプラグインとなります。
主な機能としては以下があります。

* 特徴量ロギング ([logging-features](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/logging-features.html))
* ランキングモデル実行による検索結果のリランキング ([searching-with-your-model](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/searching-with-your-model.html))


続いて、このプラグインを利用する具体的な流れは以下となります。
今回のデモではこれらがランキング学習用ライブラリの実行と絡む部分で使っており分かりづらいところもあるかと思いますが、この流れを把握出来ていると理解が進むと思います。

No|手順|備考
--|--|--
(初回のみ)|feature storeを有効化する|feature storeは、特徴量セットやモデルに関するメタデータを格納するインデックスに相当<br>[link](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/building-features.html#initialize-the-default-feature-store)
1|Elasticsearchで計算する特徴量セットを定義する|デモの手順「モデル学習とデプロイ」で実行<br> [link](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/building-features.html#create-a-feature-set)
2|機械学習モデルを構築してElasticsearchにアップロードする|デモの手順「モデル学習とデプロイ」で実行<br> [link](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/training-models.html)
3|アップロードしたモデルを検索クエリで指定して検索する|デモの手順「検索する」で実行<br>[link](https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/searching-with-your-model.html)



# 作成したデモコード用のリポジトリは以下

https://github.com/kimai07/elasticsearch-ltr-demo


# 実行環境

デモを実行するためには以下実行できる環境が必要となります。

* Java
* Python3.9
* Poetry


# デモ実行

Elasticsearchで計算された特徴量を用いてランキング用学習モデルを作成し、モデル有無によって検索結果のランキングがどう変わるかを見ることが出来るデモとなります。
デモの手順は以下となります。

手順|備考
---|---
事前準備|* データやライブラリのダウンロード<br>* Elasticsearchコンテナ起動（バージョンは7.17系）<br>* 実行環境のセットアップ（Poetry環境セットアップや環境変数の設定）
インデックス作成・データ投入|* tmdbインデックスの作成<br>* tmdbデータのbulk insert実行
モデル学習とデプロイ|* 特徴量セットのセットアップ<br>* 判定データ（評価データ、正解データ）への特徴量付加<br>* [RankLib](https://sourceforge.net/p/lemur/wiki/RankLib/)ライブラリを使っての学習モデル作成とデプロイ
検索|* 検索結果のリランク有りの検索<br>* 検索結果のリランク無しの検索


これらに関してはGitHubリポジトリのREADMEに細かく記載していますので、この記事ではポイントのみ記載したいと思います。

## 事前準備

#### データとライブラリ

検索データと学習モデルを作成するライブラリは以下を利用します。

```shell
$ cd app
$ bash prepare.sh
```

* 検索データ：[TMDB](http://themoviedb.org)の映画データ（tmdb.json）
* ランキング学習のライブラリ：RankLibPlus-0.1.0.jar


#### Elasticsearchコンテナ起動

Elasticsearchコンテナ起動は以下コマンドで容易に起動することが出来ます。

```shell
$ docker-compose up -d es
```

* イメージなど起動コンテナの内容は [docker-compose.yml](https://github.com/kimai07/elasticsearch-ltr-demo/blob/main/docker-compose.yml) を参照
* また、[es/Dockerfile#L3](https://github.com/kimai07/elasticsearch-ltr-demo/blob/main/es/Dockerfile#L3)でプラグインをインストール


#### 実行環境のセットアップ

ここでは、Poetry環境セットアップや環境変数を設定します。

```shell
$ cd app
$ poetry install
```

```shell
$ export ES_HOST=localhost
$ export ES_PORT=9200
$ export APP_DIR_BASE=../../..
```

## インデックス作成・データ投入

```shell
$ cd app/demo ### 以降も同様にapp/demoに移動後poetryコマンドを実行してください

$ poetry run python index_tmdb.py
```

index_tmdb.pyではtmdbデータをElasticsearchインデックスにbulk insertします。


## モデル学習とデプロイ

```bash
$ poetry run python train.py
```

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

* 特徴量セットのセットアップ
  * feature storeの有効化や特徴量セットを定義する
  * 今回用いる特徴量は「[titleフィールドのマッチ度](https://github.com/kimai07/elasticsearch-ltr-demo/blob/main/app/demo/data/feature/tmpl/query1.json)」「[overviewフィールドのマッチ度](https://github.com/kimai07/elasticsearch-ltr-demo/blob/main/app/demo/data/feature/tmpl/query2.json)」
* 判定データ（評価データ、正解データ）への特徴量付加
  * 判定データ（[sample_judgements.txt](https://github.com/kimai07/elasticsearch-ltr-demo/blob/main/app/demo/data/judgment/sample_judgments.txt)）から検索クエリに紐づくdocIDリストを取得
  * これらのIDリストを用いてElasticsearchに `_search` リクエストを行い、レスポンスには以下情報が追加で返ってくる。この情報を特徴量の値（titleフィールドのマッチ度、overviewフィールドのマッチ度）として取得・利用する
    ```json
       :
      "fields": {
          "_ltrlog": [
            {
              "log_entry1": [
                {
                  "name": "1",
                  "value": 10.357876
                },
                {
                  "name": "2",
                  "value": 9.894461
                }
              ]
            }
          ]
        },
        :
    ```
  * その後ranklib formatに変換して判定データに付加して学習のためのデータを整える
* モデル学習とデプロイ
  * RankLibを実行してモデル作成する
  * 作成されたモデルをEasticsearchへアップロード


## 検索する

```shell
$ poetry run python check_search_results.py --keyword Rambo
```

check_search_results.pyでは、指定した検索クエリ（上記では`Rambo`）を用いて以下の処理が実行されます。

* 検索結果のリランク有りの検索
* 検索結果のリランク無しの検索

実行結果としては以下のような結果が得られます。
リランク有りの場合は「titleにRamboが含まれる映画」が上位に来ていることが分かります。

```
## search with learning-to-rank (test_6 is LambdaMART model)
1 Rambo (7555, 112.72379)
2 Rambo III (1370, 11.029702)
3 Rambo: First Blood Part II (1369, 9.810427)
4 First Blood (1368, 7.9415426)
5 In the Line of Duty: The F.B.I. Murders (31362, -92.777466)
6 Son of Rambow (13258, -93.76985)
7 Spud (61410, -96.2297)

## search without learning-to-rank
1 Rambo (7555, 12.318474)
2 Rambo III (1370, 11.95039)
3 First Blood (1368, 11.220095)
4 Rambo: First Blood Part II (1369, 11.220095)
5 In the Line of Duty: The F.B.I. Murders (31362, 7.906151)
6 Son of Rambow (13258, 6.913769)
7 Spud (61410, 4.4539194)
```




# 判定データを追加してみる

ここまでは既存のデータをそのまま利用して動作させましたが、せっかくなので新たに検索クエリ「matrix」(qid:4) 用の判定データを追加し検索結果のリランクまで行ってみたいと思います。
gradeはタイトルを見て完全一致なら4、部分一致なら3でここではセットしました。

```
# qid keyword
# qid:1: rambo
# qid:2: rocky
# qid:3: bullwinkle
# qid:4: matrix <=== 追加
#
# grade (0-4)	queryid	docId	title
:
:
0	qid:3 #	1374	Rocky IV
4	qid:4 #	603	The Matrix <=== qid:4の行を追加（以下同様）
3	qid:4 #	604	The Matrix Reloaded
3	qid:4 #	55931	The Animatrix
3	qid:4 #	605	The Matrix Revolutions
0	qid:4 #	1857	The Transformers: The Movie
0	qid:4 #	10999	Commando
0	qid:4 #	4247	Scary Movie
```

このデータを作成後、train.pyを実行し、キーワード `matrix` で検索します。
`search with learning-to-rank` の結果を見ると、CommandoやThe TransformersよりもMatrix関連の映画が上位に来ていることが確認出来ます。

```shell
$ poetry run python train.py

$ poetry run python check_search_results.py --keyword matrix

## search with learning-to-rank (test_6 is LambdaMART model)
1 The Matrix (603, 110.75354)
2 The Matrix Reloaded (604, 8.057925)
3 The Animatrix (55931, 7.502325)
4 The Matrix Revolutions (605, 7.104662)
5 Commando (10999, -88.80388)
6 The Transformers: The Movie (1857, -90.88333)
7 Scary Movie (4247, -92.29588)
8 Looker (21874, -92.54379)

## search without learning-to-rank
1 Commando (10999, 11.744598)
2 The Animatrix (55931, 10.780877)
3 The Matrix (603, 10.357876)
4 The Transformers: The Movie (1857, 9.806382)
5 The Matrix Reloaded (604, 9.351768)
6 The Matrix Revolutions (605, 8.93568)
7 Scary Movie (4247, 8.387732)
8 Looker (21874, 8.139824)
```

# 特徴量定義を追加してみる

今度は特徴量を抽出するためのクエリファイル`app/demo/data/feature/tmpl/query3.json`を以下内容で追加します。
original_titleフィールドに指定キーワードが含まれているかのマッチ度を特徴量の値として追加します。既にtitleフィールドが特徴量としてあるのでスコアに大きな変動はありませんが、特徴量の変更を試す意での追加となります。

```json
{
    "query": {
        "match": {
            "original_title": "{{keywords}}"
        }
    }
}
```

追加後にtrain.pyを実行し、キーワードは上記同様に `matrix` で検索します。
判定データ追加時の検索結果と比較すると、スコアが少し変わっていることが分かります。

今回追加した特徴量は検索結果のランキング変動に影響を与えるような特徴量ではなかったことや既に検索結果のランキングが満足のいく結果になっていたため、ランキング変動はありませんでした。
検索結果がいまいちなランキングになっており選定する特徴量次第では、ランキング改善の効果が期待できます。

```shell
$ poetry run python train.py

$ poetry run python check_search_results.py --keyword matrix

## search with learning-to-rank (test_6 is LambdaMART model)
1 The Matrix (603, 110.752945)
2 The Matrix Reloaded (604, 8.028405)
3 The Animatrix (55931, 7.4981904)
4 The Matrix Revolutions (605, 7.085392)
5 Commando (10999, -88.88254)
6 The Transformers: The Movie (1857, -90.919815)
7 Scary Movie (4247, -92.33237)
8 Looker (21874, -92.58028)

:
```

特徴量追加の前後のモデル内容を比較すると、特徴量の重みが1つ追加されていること確認できます。
以下は`Linear Regression (model_type=9)`で作成されたモデル（model.txt）の内容となります。

##### 特徴量が追加される前

```shell
## Linear Regression
## Lambda = 1.0E-10
0:0.2886239443865109 1:0.2886239443865109 2:0.2668819962183175
```

##### 特徴量が追加された後

```shell
## Linear Regression
## Lambda = 1.0E-10
0:0.22138590520159865 1:0.22138590520159865 2:0.12127698297586667 3:-0.12453342403102677
```

ここでは、シンプルなLinear Regressionのモデルの内容を挙げましたが、他モデルでも同様に確認することが出来ます。

# おわりに

この記事では、Elasticsearch-LTRのデモの手順について記載しました。
また、判定データへのデータ追加や特徴量定義の追加も簡易ではありますが行ってみました。
今回は同じような特徴量を定義するところで終えたため、今後は様々な特徴量を定義する方法を知って、容易に定義することが出来るようになれればと思ってます。