ElasticsearchによるLearning To Rank(LTR)入門

# はじめに

Elasticsearchではv8.12から[Learning To Rank](https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html)という機能が実装されました。これまでにも[コミュニティープラグイン](https://github.com/o19s/elasticsearch-learning-to-rank)では同様のことが実現できていましたが、今回Elasticが公式にサポートしましたので概要について紹介したいと思います。

https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html

検索の良し悪しは主に以下の観点で評価できます。

- 適合率 / Precision : どれだけノイズが少ないか
- 再現率 / Recall : どれだけもれなく検索できているか

Learning To Rank（LTR）はこのうち適合率 / Precisionを、事前に用意したデータを学習することによって向上させるための仕組みです。

この機能はElasticsearchの外部で機械学習によって作成したモデルを使って実現します。流れとしてはData Frame Analyticsやベクトル検索で実現しているのと同様、Pythonでトレーニングを実行し、Elasticが公開している[Eland](eland)というライブラリを使ってElasticsearchに学習済みモデルをアップロードします。機械学習には[XGBoost](https://xgboost.readthedocs.io/en/stable/)が利用されます。

このような単にElasticsearchのAPIやKibanaのUIを使うだけで完結する機能ではないため、ElasticではLearning To Rankを一通り実現する流れを実装した以下のPython notebookを公開しています。

https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb

このQiitaの記事では、基本的に上記Python NotebookをベースにLearning To Rankの実装方法を紹介します。このNotebookはかなり丁寧かつそのまま実行できるように作られていますので、ざっと日本語でこの記事に目を通したら、実際にこのNotebookを実行させながら動作を確認すると良いでしょう。また一部Notebookで説明が不足している部分についてはこの記事で補足していきます。


# Learning To Rankとは

Learning To Rank（LTR）は検索結果のランキング精度を向上させる手法の一つです。検索した時、ユーザーが求めている情報が上位に表示されていることはほとんどの検索システムで重要です。しかし例えば社内ドキュメント検索システムとECサイトでの検索結果に求められているものは違います。それぞれのシステム固有、あるいは実際に利用するユーザーに固有の検索結果が求められることもあります。

Learning To Rankでは機械学習の手法を使い、トレーニングデータによってクエリー毎に（あるいはそのほかのパラメータも使い）どのような結果が望ましいのかを事前に学習することで、検索時により高精度なランキングを実現します。

Learning To Rankを使うと、まず通常の検索で検索結果のドキュメントを取得した後、機械学習によって作成されたモデルを使い、関連度の高い順にリランキングします。以下、公式ドキュメントからのイメージ図です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/9c7d0875-eddb-7a2b-aca1-b7783336c606.png)


# Learning To Rank実装の流れ

正直に言って、Learning To Rankでの検索を実装するのはElasitcsearchで通常の検索を実装するのに比べてかなり難易度が高いです。Pythonによる機械学習のためのコーディングが必須になりますので、その点は最初に理解しておいてください。

通常、大きな実装の流れは以下のようになります。

1. 事前学習（Python）
    1. 検索ログなど、学習のための元になる、検索時のパラメータと関連度、および検索結果ドキュメントをセットにしたジャッジメントリストを用意する
    1. ジャッジメントリストに対して、適切な特徴量を与える
        - 特に、Elasticsearchでの検索でそのドキュメントに与えられるBM25のスコアを付与する
    1. ジャッジメントリストと特徴量の定義からトレーニング用のデータセットを作成する
    1. 機械学習を実施し、モデルを作成する
    1. 出来上がったモデルをElasticsearchにアップロードする
1. 検索実行
    1. モデルを作成した時に作成した特徴量を使って検索を実行する

以下、各ステップについて詳しく見ていきます。


## ジャッジメントリストの準備

Learning To Rankは機械学習をベースにしたリランキング手法です。そのため学習元となる教師データが必要です。Learning To Rankではこの教師データのことをジャッジメントリストと呼んでいます。

ジャッジメントリストは、例えば以下のような形をとります。ここでgradeはクエリーに対するドキュメントの関連度を表す数値で、 0/1のbooleanの値が使われることもあります（Notebookの例はbooleanです）が、この表の例は大きい方が高い関連度を示す整数になっています。qid:1に注目すると以下のことがわかります。

 - クエリー"foo"に対してドキュメント"doc-2", "doc-3", "doc-1"がヒット
 - クエリーに対する関連度は"doc-2" -> 0, "doc-3" -> 4, "doc-1" -> 3

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/bdb50455-46f5-db38-c666-686423a8dbfa.png)

ここでgradeをどのように作成するか、対象とするシステムによって運用者が検討する箇所になります。例えば検索結果に対するCTRなどを計測しておき、CTRが高いものに対して高いgradeを設定することなども考えられるでしょう。

ノートブックではこのジャッジメントリストは[Elasticが公開しているリポジトリ](https://github.com/elastic/elasticsearch-labs/tree/main/notebooks/search/sample_data/learning-to-rank)からDataframeとしてダウンロードするようになっています。

```python
judgments_df = pd.read_csv(JUDGEMENTS_FILE_URL, delimiter="\t")
```


## 特徴量/featureの設定

ここがElasticの提供するLearning To Rankで特徴的な箇所だと思われます。今あるジャッジメントリストを見ると、クエリー文字列に対してドキュメントの関連度が定義されているので、もう学習ができそうにも見えるかもしれません。しかし、一般的に機械学習では文字列は直接学習に利用することはできません。Elasticseachで言うところのkeywordのように、いくつかの種類をとるような文字列パターンのみなら[エンコーディング](https://qiita.com/search?q=%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E3%80%80%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92)というテクニックを使って数値化する方法も使えるかもしれませんが、通常クエリーには自然文が入ってきます。時には比較的長い文章になることもあるでしょう。そういった文章に対して、適切にエンコーディングを行うことは難しいものと考えられます。

そこでElasticsearchでは、そのクエリーを実際にElasticsearchで検索してみて、その結果得られる検索スコアを特徴量とする手法を提案しています。この検索結果のランキングを使った特徴量抽出器として`QueryFeatureExtractor`がElandのモジュールとして提供されています。以下、特徴量抽出のロジックを定義している箇所を見てみましょう。

```python
from eland.ml.ltr import LTRModelConfig, QueryFeatureExtractor

ltr_config = LTRModelConfig(
    feature_extractors=[
        # For the following field we want to use the score of the match query for the field as a features:
        QueryFeatureExtractor(
            feature_name="title_bm25", query={"match": {"title": "{{query}}"}}
        ),
        QueryFeatureExtractor(
            feature_name="actors_bm25", query={"match": {"actors": "{{query}}"}}
        ),
        # We could also use a more strict matching clause as an additional features. Here we want all the terms of our query to match.
        QueryFeatureExtractor(
            feature_name="title_all_terms_bm25",
            query={
                "match": {
                    "title": {"query": "{{query}}", "minimum_should_match": "100%"}
                }
            },
        ),
        QueryFeatureExtractor(
            feature_name="actors_all_terms_bm25",
            query={
                "match": {
                    "actors": {"query": "{{query}}", "minimum_should_match": "100%"}
                }
            },
        ),
        # Also we can use a script_score query to get the document field values directly as a feature.
        QueryFeatureExtractor(
            feature_name="popularity",
            query={
                "script_score": {
                    "query": {"exists": {"field": "popularity"}},
                    "script": {"source": "return doc['popularity'].value;"},
                }
            },
        ),
    ]
)
```

ここで合計５つの特徴量を定義しています。最初の特徴量は名前を`title_bm25`とし、`title`フィールドに対して`query`の検索文字列で検索したときのランキング値（BM25のスコア）を特徴量として抽出します。また最後の特徴量`popularity`については、`script_score`を使って元インデックスの`pupularity`フィールドに保存されている数値をそのまま特徴量として抽出します。


## トレーニング用のデータセット作成

上記はどのように特徴量を抽出するかのルールの定義です。この定義ができたら、実際にトレーニング用のデータをElasticsearchに接続してそれぞれのジャッジメントリストにある項目に対して検索を実行し、特徴量を抽出して最終的な学習用のデータセットを作成します。

```python
import numpy

from eland.ml.ltr import FeatureLogger

# First we create a feature logger that will be used to query Elasticsearch to retrieve the features:
feature_logger = FeatureLogger(es_client, MOVIE_INDEX, ltr_config)


# This method will be applied for each query group in the judgment log:
def _extract_query_features(query_judgements_group):
    # Retrieve document ids in the query group as strings.
    doc_ids = query_judgements_group["doc_id"].astype("str").to_list()

    # Resolve query params for the current query group (e.g.: {"query": "batman"}).
    query_params = {"query": query_judgements_group["query"].iloc[0]}

    # Extract the features for the documents in the query group:
    doc_features = feature_logger.extract_features(query_params, doc_ids)

    # Adding a column to the dataframe for each feature:
    for feature_index, feature_name in enumerate(ltr_config.feature_names):
        query_judgements_group[feature_name] = numpy.array(
            [doc_features[doc_id][feature_index] for doc_id in doc_ids]
        )

    return query_judgements_group


judgments_with_features = judgments_df.groupby(
    "query_id", group_keys=False
).progress_apply(_extract_query_features)
```

この処理によって、最終的に学習で利用するデータフレーム`judgments_with_features`が作成されます。

この処理はジャッジメントリスト全体に対してElasticsearchへの検索処理を行うため、それなりに時間がかかります。Elasticsearchがリモートにある場合などはネットワークの状況などにもよりますが、私の環境ではおおよそ10分くらいかかりました。結果として以下のようなデータフレームが出来上がります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/e69b8459-98b5-ba39-f0e6-d02a0bbd98ac.png)



## 学習実行

学習用のデータセットが出来上がったので、ランキング用の機械学習モデルを作成します。[公式ドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank-model-training.html#learning-to-rank-model-deployment)によると以下のモデルが現在サポートされています。

- [DecisionTreeRegressor](https://scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeRegressor.html)
- [RandomForestRegressor](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html)
- [LGBMRegressor](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMRegressor.html)
- [XGBRanker](https://xgboost.readthedocs.io/en/stable/python/python_api.html#xgboost.XGBRanker)
- [XGBRegressor](https://xgboost.readthedocs.io/en/stable/python/python_api.html#xgboost.XGBRegressor)

NotebookではこのうちXGBRankerを使っています。各種パラメータのチューニングなど、XGBoostによるLearning To Rankモデルの実装については以下の公式サイトも参照してください。

https://xgboost.readthedocs.io/en/latest/tutorials/learning_to_rank.html

では、Notebookでのモデル学習の実装を見てみましょう。

```python
from xgboost import XGBRanker
from sklearn.model_selection import GroupShuffleSplit


# Create the ranker model:
ranker = XGBRanker(
    objective="rank:ndcg",
    eval_metric=["ndcg@10"],
    early_stopping_rounds=20,
)

# Shaping training and eval data in the expected format.
X = judgments_with_features[ltr_config.feature_names]
y = judgments_with_features["grade"]
groups = judgments_with_features["query_id"]

# Split the dataset in two parts respectively used for training and evaluation of the model.
group_preserving_splitter = GroupShuffleSplit(n_splits=1, train_size=0.7).split(
    X, y, groups
)
train_idx, eval_idx = next(group_preserving_splitter)

train_features, eval_features = X.loc[train_idx], X.loc[eval_idx]
train_target, eval_target = y.loc[train_idx], y.loc[eval_idx]
train_query_groups, eval_query_groups = groups.loc[train_idx], groups.loc[eval_idx]

# Training the model
ranker.fit(
    X=train_features,
    y=train_target,
    group=train_query_groups.value_counts().sort_index().values,
    eval_set=[(eval_features, eval_target)],
    eval_group=[eval_query_groups.value_counts().sort_index().values],
    verbose=True,
)
```

全ての行が重要な気もしますが概要のみ説明すると、データセットを70%を学習用、30%を検証用に分割したのち、`ranker.fit`で学習を実行しています。

利用する特徴量はデータフレームの`ltr_config.feature_names`カラムで定義されているものになります。Notebookで`ltr_config.feature_names`の内容を確認すると以下のようになっています。

```
['title_bm25',
 'actors_bm25',
 'title_all_terms_bm25',
 'actors_all_terms_bm25',
 'popularity']
```

これらをパラメータとして、`grade`を予測するモデルを作成しているわけですね。


## モデルの評価

さて学習が終わったら、どのようなモデルが作成されたのかを確認しましょう。まずはNotebookにあるとおり、Feature Importanceのグラフを表示してみましょう。

```python
from xgboost import plot_importance

plot_importance(ranker, importance_type="weight");
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/c2357010-b083-6ce4-9b33-46bee914aa11.png)

これを見ると、popularityのスコアが最も関連度に貢献していると言うことがわかります。ついでtitleフィールドのBM25スコアですね。この結果を見るだけでもこれまでやってきたことに大きな意味があると思うのですが、ここからわかるのは「テキストの関連度がどうこうよりも、実はその作品がどれだけ人気があるかが一番重要」と言う身も蓋もない結論ではないでしょうか。もちろんその他の項目が結果に関与していないわけではないですが、今回のサンプルは全てElasticsearch側でスコアリングできる内容を使ってリランキングしているため、検索結果のチューニングをしたいだけであればこの結果を元にスコアリングの値をBoost等でチューニングする方法も十分考えられるでしょう。

ただこの記事はLearning To Rankの技術紹介なので話を進めましょう。オフィシャルのNotebookではモデルの評価に関する話題はここまでなのですが、実際の予測精度についてはまだ分析していません。また学習の際にわざわざ評価用のデータを切り出しているので、学習データと評価データでそれぞれ予測精度がどうだったのかを分析してみましょう。

学習している箇所で、以下のようにパラメータ`eval_set`と`eval_group`を書き換えます。

```python
ranker.fit(
    X=train_features,
    y=train_target,
    group=train_query_groups.value_counts().sort_index().values,
    eval_set=[
        (train_features, train_target),
        (eval_features, eval_target)
    ],
    eval_group=[
        train_query_groups.value_counts().sort_index().values,
        eval_query_groups.value_counts().sort_index().values
    ],
    verbose=True,
)
```

この状態で再度学習したのち、以下のコードでそれぞれndcgのスコアをグラフ化してみましょう。

```python
from matplotlib import pyplot

# retrieve performance metrics
results = ranker.evals_result()
# plot learning curves
pyplot.plot(results['validation_0']['ndcg@10'], label='train')
pyplot.plot(results['validation_1']['ndcg@10'], label='eval')
# show the legend
pyplot.legend()
# show the plot
pyplot.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/b6d0d2c2-c39f-12ce-e5f3-d14bd3ed3a36.png)

ラウンドが進んで評価データが悪化している様子も見られず、過学習は起こしていないようです。学習データでおよそ0.9のところ、評価データで0.885くらいの性能のようなので良い結果が得られているように見えます。


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

良いモデルが作成できたようなのでElandを使ってモデルをElasticsearchにアップロードします。

```python
from eland.ml import MLModel

LEARNING_TO_RANK_MODEL_ID = "ltr-model-xgboost"

MLModel.import_ltr_model(
    es_client=es_client,
    model=ranker,
    model_id=LEARNING_TO_RANK_MODEL_ID,
    ltr_model_config=ltr_config,
    es_if_exists="replace",
)
```

ここで`ltr_model_config=ltr_config`を指定していることに注意しましょう。これによって、Elasticsearchにこのモデルがどのような特徴量を使って動作しているのかを知らせています。

アップロードされたモデルをKibanaから確認します。機械学習 > 学習済みモデルにアップロードしたモデルが表示されているはずです。ここで「構成」のタブを開くと、Python側の`ltr_config`で指定したのと同じ情報が確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2628554/8dd5e661-d69c-aede-1d19-5bac4569826c.png)


## 検索実行

ではいざ検索してみましょう。Learning To Rankを使った検索クエリーは以下のような形になります。

```
GET /movies/_search
{
   "query" : {
      "multi_match" : {
         "query": "star wars",
         "fields": ["title", "overview", "actors", "director", "tags", "characters"]
      }
   },
   "rescore" : {
      "window_size" : 50,
      "learning_to_rank" : {
         "model_id": "ltr-model-xgboost",
         "params": { 
            "query": "star wars"
         }
      }
   }
}
```

まず通常のqueryとしてQueryDSLを渡し、検索にヒットするドキュメントを見つけます。その上`rescore`で先ほど作成したモデルを使い、並び順を改めて計算しなおします。この例ではまず最初のクエリーにヒットするトップ50件を取得し、その後リランキングを行なっています。

モデルさえ作成できていれば、Learning To Rankのリランキングを使った検索の実行は簡単ですね。

# まとめ

Learning To Rankを使うことで、機械学習によって過去の実績データから適切なランキングで検索結果を際作成することができます。これまでコミュニティープラグインはありましたが、今回Elasticが公式実装を提供しましたので、より幅広い活用ができるようになるのではないでしょうか。この実装では、Python側の学習実行時、およびElasticsearch側での検索時でElasticsearchによるスコアリングを特徴量として利用できるようになっています。この特徴をうまく使って賢いランキングを提供してください。

# Webinar

Leaning to rankの内容について2024/7/31にWebinarを開催します。ご興味ある方はぜひご視聴ください。

https://www.elastic.co/jp/virtual-events/achieve-better-search-rankings-with-elasticsearch