Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

実務で使えるニューラルネットワークの最適化手法

メリークリスマス。
@tereka114です。

本記事はDeep Learning論文紹介 Advent Calendar 2019の25日です。
qiita.com

私はKaggleの画像コンペに頻繁に参加しています。
そのときに、毎度選定にこまるのがニューラルネットワークの最適化手法(Optimizer)です。
学習率やWeight Decayなどハイパーパラメータが多く、選択パタンが無数にあると感じています。

そのため、Kaggleでよく利用される(されうる)最適化手法を振り返ります。
もちろん、実務でも十分使えるので、皆さんの学習に活かしてくれると幸いです。

最適化手法

今回紹介するのは次の最適化手法です。
Kaggle、もしくは、実務でもこの5種類を抑えておけば、問題ないと思っています。むしろ、十分すぎるぐらい。
調査(個人的主観含む)でつけた事前の軸とその評価を記載します。

最適化手法 精度 収束速度 安定性
SGD ×
Adam × ×
AdamW ×
Adabound
RAdam

精度:収束したときに高いAccuracyを出せるか、など
収束速度:学習の収束まで早くいけるか
安定性:勾配が爆発することなく、最適解を学習できるか

SGD(Momentun)

最も標準的な最適化手法です。
得られたパラメータの勾配を学習率で掛け算し、減少させます。また、Momentunを設定することで、収束が高速化します。

SGDは学習率の設定が難しく、収束するか否かはこの設定が適切か否かに関わっているといっても過言ではないでしょう。
多くのライブラリでは学習率0.01, Momentunは0.9に設定されています。

学習済モデルを学習する場合ではなければ、この設定を利用すれば大丈夫です。
学習済モデルの場合では0.001を利用することをお勧めします。
学習率が高い場合、学習済モデルのパラメータが壊れてしまい、ImageNetの事前学習で獲得された汎用的な特徴抽出機が壊れます。

Adam

Adamの良い点は、SGDよりも収束が早い点です。
領域分割(Semantic Segmentation)では、SGDだと収束が非常に遅く、かつ、局所最適解にたどり着くことが多いです。
ただ、物体検出のFasterRCNNや領域分割モデルのUNetやFPNなどパラメータも多く、タスクが難しい場合にSGDよりよい解にたどり着くこともそれなりにあります。

しかし、このモデルですが、収束が安定しないことが多いです。
学習率(Alpha,lr)の設定を誤るだけで、勾配が爆発し、その結果、パラメータの更新幅が大きくなり、オーバーフローを起こすことも多々ありました。
学習済モデルを利用する場合は0.0001を利用すると安定することが多いです。

AdamW

[1711.05101] Decoupled Weight Decay Regularization

Adamの基本のアルゴリズムからWeight Decayに関する式を変更しました。
自動調整された学習率の場合は、もともと期待していたWeight Decayの結果が得られず、精度が下がる事象が得られるようです。
その事象を回避するために式を変更しています。

具体的な数式は論文を参照ください。
また、PyTorchであれば、既存で実装されています。

pytorch.org

AdaBound

[1902.09843] Adaptive Gradient Methods with Dynamic Bound of Learning Rate

Adamは時々、極端に大きな学習率になることから、SGDと比較して収束が安定しないことが知られています。
そのため、Adamに対して動的に学習率をクリップすることにより、収束を安定させています。
これにより、収束を安定させつつ、精度を同等にしています。
ハイパーパラメータについても、final_lrと呼ばれるクリップで利用するパラメータを適切に設定することで学習可能になります。

PyTorchの実装はこちら
github.com

RAdam

[1908.03265] On the Variance of the Adaptive Learning Rate and Beyond

Warmupと呼ばれる最初に小さい学習率で学習し、通常の学習率で学習させていく手法は収束の高速化、及び、安定性に貢献しています。
また、このWarmupを人手で行うのではなく、分散を考慮した学習率を推定しています。
その仕組みをAdamに組み込んだのがRAdam(Rectified Adaptive Learning Rate)です。

何度かこの手法を活用して学習しましたが、Adamと比較してハイパーパラメータに対して過敏ではないため、扱いやすいです。
PyTorchの実装はこちら

github.com

実験

CIFAR10を使って各Optimizerを用いて実験を試みました。
実験の条件は次の通りです。

項目
誤差 交差エントロピー誤差
データ拡張(Data Augmentation) なし(本実験ではそこまで影響しないため)
アーキテクチャ ResNet50
Batch size 32
Epoch 50

蛇足ではありますが、精度が論文などの実験と比較して低めに出ているのはおそらく、Augmentationを実施していないからです。

Optimizer間の比較

SGDは0.01, その他は0.001の学習率で比較しました。
実験結果は次の通りです。AdamWのWeight Decayは0.00001です。

計算したグラフは次の通りです。

f:id:acro-engineer:20191225080743p:plain
OptimizerのAccuracy比較

少々わかりずらいので順列を示すと次の通りです。

RAdam > AdamW > Adam > Adabound = SGD

Weight Decayを除いて、デフォルトパラメータで学習を行ったので、最適なパラメータは他にあるかもしれません。
ただ、RAdam、AdamWのようなAdamの後継を使うと他と比べて、精度が高い学習を進められそうです。

RAdamとAdamの学習率

Adamはlrが大きいと勾配が発散し、局所最適解に落ちたり、収束しなくなることが(私の)経験的に得られています。
例えば、Segmentationの場合は学習率が0.001だと高すぎて収束せず、誤差の値がnanになります。
しかし、RAdamは最初に自動的にWarmupされているので収束が安定しそうです。

今回は学習率に関する安定性の実験を行おうと思います。
Adamの場合

f:id:acro-engineer:20191225080838p:plain
Adamの学習率比較のグラフ

RAdamの場合

f:id:acro-engineer:20191225080851p:plain
RAdamの学習率比較のグラフ

AdamとRAdamで大きく差分は開かず、学習率の影響を受けている印象です。
ただ、分類ではなく、もう少し難しいタスクなってくると大きく収束に影響するかもしれません。

AdamWのWeight Decayの値

Weight Decayの値を利用した場合のAdamとAdamWの比較です。
AdamWの方が精度が高く出ているので、AdamWの方が使っていくのには良いのではと考えています。

f:id:acro-engineer:20191225080905p:plain
AdamとAdamWの比較

最後に

Optimizer一つとっても適切にチューニングしなければうまくいかないのでまだまだニューラルネットワークは難しいと感じるところです。
期待とは少し異なった部分もあるのでデータセットに応じて、一つひとつ実験を重ねていく必要があると思っています。
おそらく、データセットによっても結果が変わるので気が向けばしっかりと実験してみたいと思います(また新しいのでそうですし、、)。

では!また、新年もよろしくおねがいします。

Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

Kaggle Masterと働きたい尖ったエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Amazon Kinesis Video StreamsのWebRTCの実力

アドベントカレンダー形式のチョコが今日になっても残って、どこかでずらしてしまった@phonypianistです。

最近、Amazon Kinesis Video Streamsを使って、遠隔ロボットからの映像を
画面で表示するシステムを作りました。
が、5秒程度のタイムラグが発生。
ロボット周辺の様子を確認する程度であれば問題ありませんが、
遠隔操作に向かない。。

そんな中、Kinesis Video StreamsがWebRTCに対応したというニュースが・・・!
aws.amazon.com

WebRTCは、ウェブブラウザ等でリアルタイムな通信を可能にする仕組みであり、
WebRTCを用いると、映像配信のタイムラグもかなり抑えられるとのこと。

いったいどれくらいのタイムラグになるのか、
次の構成でRaspberry Pi用カメラの映像を配信してみました。
f:id:acro-engineer:20191224000637p:plain
Raspberry PiもPCもWiFiでインターネットに接続しています。

で、実際に映像配信した様子はこちら。

左側のストップウォッチをRaspberry Piで撮影したものを映像配信し、
PCで受信したストップウォッチ映像を右側に表示しています。
今回の検証では、おおよそ、0.4~0.5秒程度のタイムラグで配信できているようです。
f:id:acro-engineer:20191223235913p:plain

使用する機材やネットワークによって、タイムラグの増減はありますが、
今までのAmazon Kinesis Video Streamsに比べると、圧倒的に速いですね!


また、Amazon Kinesis Video StreamsのWebRTCの場合、MasterとViewerの2つの役割があります。
名前だけ見ると、Masterは映像配信側、Viewerは受信側と捉えがちですが、
WebRTCの場合は双方向通信が可能です。
つまり、Viewer側で撮影した映像をMasterで確認できます。
(ちなみに、Masterに対してViewerを複数接続できます。)
f:id:acro-engineer:20191224171122p:plain

この仕組みを使えば、遠隔機器が配信した映像を確認しながら
リアルタイムに音声で現場に伝えるようなことが簡単に構築できます。

IoTへの適用の幅が広がりますね!

それでは。

Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

Kaggle Masterと働きたい尖ったエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

ニューラルネットワークの学習収束

こんにちは。
数学好きの4年目、maron8676です。
遅れましたが、本記事はDeep Learning論文紹介 Advent Calendar 2019 - Qiitaの15日目の記事です。

本記事ではニューラルネットワークの学習収束について書かれた論文であるGradient Descent Provably Optimizes Over-parameterized Neural Networks[1]の内容紹介をします。
述べられている結果そのものは深いネットワークについてではないですが、Deep Learningにもいずれ関わる話になると考えています。

目次

1. ニューラルネットワークについて
2. 学習の収束について
3. 主要な結果
4. 結果の実用性
5. まとめ

1. ニューラルネットワークについて

ニューラルネットワーク(NN)は機械学習でたびたび使われる数理モデルです。
学習データを入力としたときに損失関数値が小さくなるような最適化問題を解き、分類、物体検出などのタスクを行うモデルを作ることができます。

NNは、よい分類精度、検出率を達成することが(経験的に)知られていますが、いわゆる「学習が収束しない」という問題があることも知られています。
今回紹介する論文は、この「学習が収束しない」という問題に対して理論的な解析を試みています。
2018/10にsubmitされたようですが、既に164回引用(Google Scholarで調査 2019/12/19)されており注目度の高い論文だと思います。

2. 学習の収束について

2.1. 学習の収束とは

論文[1]で述べられている「学習の収束」とは、パラメータ更新t回目でのNN出力を\mathbf{x}(t)、学習データにおける出力期待値を\mathbf{y}としたときに以下が成り立つことです。f(x)は入力データxに対するNNの出力として書いています。
\|f(\mathbf{x}(t)) - \mathbf{y}\|^2_2 \leq \mathrm{exp}(-\alpha t)\|f(\mathbf{x}(0))-\mathbf{y}\|^2_2 \quad (\mathbf{y} \in Y, \alpha>0)
この式から以下を導出できます。
\lim_{t \rightarrow \infty} \|\mathbf{y} - f(\mathbf{x}(t))\|_2 = 0
NNの出力と期待値の距離が0に収束することがより分かりやすくなりました。
なぜ最初の表記になっているかというと、どのくらいの速さで収束するかを表現できるからです。
具体的に\alphaが何の値になるかは論文内で示されているため、実際に学習を行う際にはNN出力の期待結果である\mathbf{y}と学習経過である\mathbf{x}(t)の距離がどのくらい近づいているかを計算できます。

2.2. 収束すると何がうれしいか

収束することが保証されていると、
1. 収束させること自体に労力を使わなくていい
2. 選択したモデル、特徴量、損失関数をより正確に評価することができる
といういいことがあります。

3. 主要な結果

論文[1]で述べられている主要な結果を紹介します。
この論文の新しい点は、モデルの出力f(\mathbf{x})を直接解析しているところにあります。
既存研究では、NNの各重みの変化に注目して解析を行っていましたが、この論文では重み値を直接取り扱わず、モデルの出力から作られるグラム行列を解析しています。

3.1. 前提条件

以下のような条件で学習を行った場合について書かれています。

入力層 隠れ層 活性化関数 出力層 損失関数 最適化手法
n次元 1つ(m次元) ReLU 全結合 二乗誤差 最急降下法

3.2. 仮定

1. ある入力データが、別の入力データの定数倍とならないこと
2. m>\Omega\biggl(\frac{n^6}{\lambda^4_0 \delta^3}\biggr) ただし、\Omegaランダウの記法、\lambda_0は入力データとNNの重み行列を掛けた結果から作られるグラム行列の最小固有値\delta \in (0,1)

3.3. 結果

3.2.の仮定のもと、NNの各パラメータを次のように初期化したときに、1-\deltaの確率で以下が成り立ちます。
\|f(\mathbf{x}(t)) - \mathbf{y}\|^2_2 \leq \mathrm{exp}(-\lambda_0 t)\|f(\mathbf{x}(0))-\mathbf{y}\|^2_2 \quad (\alpha>0)

隠れ層の重み 出力層の重み
ガウス分布\mathcal{N}(\mathbf{0}, \mathbf{I}) 一様分布:\mathrm{unif}[\{-1, 1\}]

4. 結果の実用性

学習の収束と、論文[1]のメイン結果について紹介してきたので、実用性について考えてみようと思います。
結論としては、現状の実用性は低いと思います。なぜなら、仮定で使われているm>\Omega\biggl(\frac{n^6}{\lambda^4_0 \delta^3}\biggr)の条件を満たすのが実用上厳しいためです。
また、ここで示されているのは学習の収束そのものであって、結果が過学習となってしまう可能性が残っていることに気を付ける必要があります。
しかしながら、論文中でmの制約を減らすためのアイディアに触れられていたり、今回の仮定のように隠れ層の次元をとても大きくした場合にどのような性質があるかという研究[2](Over-Parameterization)があったりするので、今後の研究が気になるところです。

5. まとめ

今回は、ニューラルネットワークの学習収束について解析している論文について紹介しました。
現状の結果はそのまま実用できるものではないですが、収束が保証されるかどうかは重要な話なので引き続きチェックしていきたいですね。

参考文献

[1] Gradient Descent Provably Optimizes Over-parameterized Neural Networks
[2] An Improved Analysis of TrainingOver-parameterized Deep Neural Networks

Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

Kaggle Masterと働きたい尖ったエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Elasticsearchのクエリ変更による影響度をオフライン評価する

概要

こんにちは、shin0higuchiです😊
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019 - Qiita20日目です。

皆さんはElasticsearchの検索クエリのランキングをチューニングする際、どのようなプロセスで実施していますか?
変更したクエリを最初からABテストにかけるのではなく、オフライン評価をおこなうことが多いのではないでしょうか?
今回はElasticsearchクエリを変更した時の、検索結果(ランキング)への影響度をオフライン評価する方法についてです。

f:id:acro-engineer:20191220013401p:plain:w600

バージョン情報など

  • Elasticsearch : 7.5.0
  • Python:3.8.0

下準備

クエリ変更云々の前に、まずベースとなるデータを用意します。
利用するのはwikipediaの日本語データです。

indexのmappingはシンプルに次のようにしました。
Wikipedia記事の内容はkuromoji analyerを適用したtextフィールドにindexします。
また、textのサブフィールドとして、bigramでtokenizeするtext.ngramフィールドも作成します。

{
  "settings": {
    "analysis": {
      "analyzer": {
        "bi-gram":{
          "tokenizer":"bi-gram"  
        }
      },
      "tokenizer": {
        "bi-gram":{
          "type":"ngram",
          "min_gram":2,
          "max_gram":2
        }
      }
    }
  },
  "mappings": {
    "properties": { 
    "text":{
      "type":"text",
      "analyzer":"kuromoji",
      "fields": {
        "ngram":{
          "type":"text",
          "analyzer":"bi-gram"
        }
      }
    }
    }
  }
}

また、今回はクエリ変更による影響度がテーマなので、変更前のクエリを事前に用意しておきます。

{
  "size":20,
  "query": {
     "match": {
        "text": "日本の電車"
     }
  }
}

影響度の測定

今回は影響度の測定のために、Ranking Evaluation APIを利用します。
Ranking Evaluation APIは、検索クエリのランキング結果に基づき、品質を測るためのものです。
概要については、昨年のアドベントカレンダーで記事にしていますのでご覧ください。
acro-engineer.hatenablog.com

本来、Ranking Evaluation APIは、正解となるランキングデータを事前に用意し、それに基づいてクエリを評価します。
本記事では、便宜上「変更前クエリの検索結果」を正解データと位置付け、クエリの変更がどの程度影響を及ぼすかを確認していきます。

ここからは、ElasticsearchへのリクエストはPythonを用いる想定で記述します。
※ 個人的にはKibanaのConsoleも好きですが、レスポンスを次のクエリに渡して実行する場合は辛いものがあります。

さて、変更前のクエリを実行してみます。
ここではIDの並び順のみ取得します。

# クエリを発行し、レスポンスから検索結果のIDリストのみを取得する(bodyは上述の変更前クエリ)
res = requests.post(ES_HOST + '/wikidata/_search', data=json.dumps(body), headers=headers).json()
org_result_set = [k['_id'] for k in res['hits']['hits']]
org_result_set

レスポンスは次の通り(size=20に設定しています)

['GMVlHm8BSg9IjoHR5Dek',
 '3tRtHm8BSg9IjoHROBB0',
 'SMpoHm8BSg9IjoHRXBsO',
 '9shnHm8BSg9IjoHRj4XA',
 '_MRlHm8BSg9IjoHRVgLP',
 'z8ZmHm8BSg9IjoHRrsBq',
 'BsVlHm8BSg9IjoHR-2i-',
 'GtBrHm8BSg9IjoHRhpPG',
 'D8hnHm8BSg9IjoHRuc3Z',
 'O9VtHm8BSg9IjoHRwSNt',
 'l8hnHm8BSg9IjoHRuctX',
 'HdBrHm8BSg9IjoHRe30U',
 '7tBrHm8BSg9IjoHRjpin',
 '4cpoHm8BSg9IjoHRpsTn',
 'esVmHm8BSg9IjoHRO8xl',
 '0MZmHm8BSg9IjoHRw_PP',
 'e9VtHm8BSg9IjoHRzTZ2',
 'JsdnHm8BSg9IjoHRAFPl',
 'HcdnHm8BSg9IjoHRAFGB',
 'kddvHm8BSg9IjoHRKJCY']


この結果を使って、Ranking Evaluationのリクエストを作成します。
まずはqueryを変更せずに実行してみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "match": {
            "text": "日本の電車"
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

今回は比較のためのmetricとして、nDCGを利用することにします。
検索結果のランキングに全く差分がなければ、nDCGの値は1になります。
逆に言えば、 元の結果から大きく外れるほど0に近づくことになります。

それでは、クエリを変更してみましょう。
textフィールドへの検索をおこなうだけでなく、text.ngramも検索対象に入れてみます。

body = {
  "requests": [
    {
      "id": "1",
      "request": {
        "query": {
          "bool": {
            "should": [
              {
                "match": {
                  "text": "日本の電車"
                }
              },
              {
                "match": {
                  "text.ngram": "日本の電車"
                }
              }
            ]
          }
        }
      },
      "ratings": []
    }
  ],
  "metric": {
    "dcg": {
      "k" : 20,
      "normalize": True
    }
  }
}

# 変更前クエリのレスポンスを元に、rating(正解データ)のリストを作成
for id in org_result_set:
  body['requests'][0]['ratings'].append({
      '_index':'wikidata',
      '_id':id,
      'rating':1
  })

requests.post(ES_HOST + '/wikidata/_rank_eval', data=json.dumps(body), headers=headers).json()

レスポンス

{'metric_score': 0.1856246546622184,
 'details': {'1': {'metric_score': 0.1856246546622184,
   'unrated_docs': [{'_index': 'wikidata', '_id': 'n8tpHm8BSg9IjoHRJMSw'},
    {'_index': 'wikidata', '_id': '7dNtHm8BSg9IjoHRB67v'},
    {'_index': 'wikidata', '_id': 'ttNtHm8BSg9IjoHRB62I'},
    {'_index': 'wikidata', '_id': 'otJsHm8BSg9IjoHRUjhV'},
    {'_index': 'wikidata', '_id': 'kdNsHm8BSg9IjoHR-pG5'},
    {'_index': 'wikidata', '_id': '-s5qHm8BSg9IjoHRpc46'},
    {'_index': 'wikidata', '_id': 'yM5qHm8BSg9IjoHRrNzE'},
    {'_index': 'wikidata', '_id': 'KcpoHm8BSg9IjoHRSQgb'},
    {'_index': 'wikidata', '_id': 'pNZuHm8BSg9IjoHRlo2o'},
    {'_index': 'wikidata', '_id': 's9ZuHm8BSg9IjoHRl46t'},
    {'_index': 'wikidata', '_id': 'QMloHm8BSg9IjoHRFpFY'},
    {'_index': 'wikidata', '_id': '1s1pHm8BSg9IjoHRyyBV'},
    {'_index': 'wikidata', '_id': 'eM1qHm8BSg9IjoHRAIyh'},
    {'_index': 'wikidata', '_id': '5stpHm8BSg9IjoHRHraG'},
    {'_index': 'wikidata', '_id': 'FdBrHm8BSg9IjoHRhpPG'},
    {'_index': 'wikidata', '_id': 'adFrHm8BSg9IjoHR6VuY'},
    {'_index': 'wikidata', '_id': '9dFsHm8BSg9IjoHRAZiH'}],
   'hits': [{'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'n8tpHm8BSg9IjoHRJMSw',
      '_score': 29.578623},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'GtBrHm8BSg9IjoHRhpPG',
      '_score': 29.015862},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7dNtHm8BSg9IjoHRB67v',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'ttNtHm8BSg9IjoHRB62I',
      '_score': 28.828218},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '7tBrHm8BSg9IjoHRjpin',
      '_score': 28.740217},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'otJsHm8BSg9IjoHRUjhV',
      '_score': 28.483252},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'kdNsHm8BSg9IjoHR-pG5',
      '_score': 28.478014},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '-s5qHm8BSg9IjoHRpc46',
      '_score': 28.416624},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'yM5qHm8BSg9IjoHRrNzE',
      '_score': 28.312298},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9shnHm8BSg9IjoHRj4XA',
      '_score': 28.297285},
     'rating': 1},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'KcpoHm8BSg9IjoHRSQgb',
      '_score': 28.2964},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'pNZuHm8BSg9IjoHRlo2o',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 's9ZuHm8BSg9IjoHRl46t',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'QMloHm8BSg9IjoHRFpFY',
      '_score': 28.217278},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '1s1pHm8BSg9IjoHRyyBV',
      '_score': 28.157154},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'eM1qHm8BSg9IjoHRAIyh',
      '_score': 28.04702},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '5stpHm8BSg9IjoHRHraG',
      '_score': 27.978518},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'FdBrHm8BSg9IjoHRhpPG',
      '_score': 27.97674},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': 'adFrHm8BSg9IjoHR6VuY',
      '_score': 27.943295},
     'rating': None},
    {'hit': {'_index': 'wikidata',
      '_type': '_doc',
      '_id': '9dFsHm8BSg9IjoHRAZiH',
      '_score': 27.888338},
     'rating': None}],
   'metric_details': {'dcg': {'dcg': 1.3068473871238868,
     'ideal_dcg': 7.040268381923511,
     'normalized_dcg': 0.1856246546622184,
     'unrated_docs': 17}}}},
 'failures': {}}

nDCGの値が「0.1856246546622184」と、変更前の検索結果とかなりの差分があることがわかります。
nDCGの値が低くなったのは、検索ワード固有のものかもしれないので、リクエストを複数の検索ワードで発行し、平均を取るなどすると良いでしょう。

これによって、クエリ変更した際、検索結果にどの程度の影響が出るかを大まかに知ることができます。
「影響度がある程度大きい」かつ「オフラインで検索結果の改善が見られた」クエリを選んでABテストにかけるという判断基準のひとつとして有用かと思います。

まとめ

  • Ranking Evaluation APIを用いることで、クエリ変更時の影響度を予測することができた
  • 影響度を予測することで、ABテストにかけるかどうかの判断材料のひとつとして利用することができる


明日は @yukata_uno さんによる記事になります。お楽しみに!



Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

ユーザに最高の検索体験を提供したいエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Elastic Stackを導入することでRDB検索を高速化する

こんにちは、Elastic Certified EngineerHiroshi Yoshioka です。この記事は Elastic stack (Elasticsearch) Advent Calendar 2019 の16日目の記事になります。

はじめに

本日のテーマは「Elastic Stackを活用したRDB検索の高速化」です。RDBを用いたシステムにおいて、データサイズが肥大化すると一般に検索性能が劣化します。特にLike演算子を用いたテキスト検索は、劣化の度合いが顕著です。

システム構成を変更せずに性能改善を行う場合、データスキーマSQLを見直すことである程度の性能改善が可能ですが、スキーマ変更に伴うアプリケーション改修量が多く、またそれに見合う性能改善を得られない場合もあります。一方で、Elastic Stackを導入し、検索処理部分のみをElasticsearchに移行するアプローチは、アプリケーション改修量は小さく、また飛躍的な性能改善を実現することが可能です。

検索性能改善:Before/After

以下は、過去Elastic Stackで性能改善した事例の性能数値です。

事例(1)

  • RDBMySQL
  • 蓄積データ:3200万件
検索時間(Before) 検索時間(After) 検索速度
30~60秒 50~200ミリ秒 300~600倍に向上

事例(2)

検索時間(Before) 検索時間(After) 検索速度
60~220秒 100~200ミリ秒 600~1000倍に向上

コメント

既存システムが遅すぎでは?という指摘があるかもしれませんが、データ設計/データサイズ/SQLによっては検索に数十秒かかるシステムをよく見かけます。これをElastic Stackを導入することで、検索時間を100ミリ秒前後に抑えることができました。

システム構成

Before/Afterのシステム構成は以下のようになります。

既存システム構成
既存システム構成
Elastic Stack導入後システム構成
Elastic Stack導入後システム構成

「Elastic Stack導入によるRDB検索高速化」のポイント

  • データは非正規化してElasticsearchに持たせる(Nested、Parent/Childは使わない)
  • 非正規化によりインデックスサイズがRDBのデータサイズよりも大きくなるので、適宜チューニングをしてサイズのダイエットをする
  • Elasticsearchへのデータ同期はLogstashを利用する
  • Elasticsearchには、検索機能として最低限必要な情報のみをデータ移行しインデックスサイズを抑える
  • Elasticsearchは検索結果のIDのみを返却し、画面に必要なデータはRDBから取得する。
  • 画面に必要なすべてのデータをElasticsearchに持たせるアプローチの方がシンプルですが、インデックスサイズが膨らみ、それに伴って必要なデータノード数も増えることに注意

最後に

Elastic Stackを導入する上で、インデックス/クエリ設計、データ同期(初期移行、定期同期、障害復旧)、運用設計など、検討事項は多々ありますが、書くと長くなりすぎるので、今回はこの辺で終ります。 RDB検索の高速化に興味がある方は、以下ページにてぜひお問い合わせください。 www.endosnipe.com


Acroquest Technologyでは、キャリア採用を行っています。

  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

ユーザに最高の検索体験を提供したいエンジニアWanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

Logstash の conf ファイルをリグレッションテストする

こんにちは。アキバです。

この記事は、 Elastic Stack (Elasticsearch) Advent Calendar 2019 の14日目です。
qiita.com

今年の8月にElastic Certified Engineerになることができました。
ということで、今年の Advent Calendar は Elastic Stack のカレンダーにお邪魔します。

構築したシステムの「正しさ」

突然ですが、Elastic Stackを使って構築する/したシステムの「正しさ」を検証していますか?
きちんと検証できていないと、インフラ/ミドルウェアの障害は影響も大きく、問題になりがちです。

そもそも、「正しさ」を検証するべき箇所は何があるでしょうか?


一つは、「設定の正しさ」があるでしょう。

  • OS環境
  • Elasticsearchのパラメータ(elasticsearch.ymlの記載や _settings API など)
  • Index Mapping

など。

これらについては、静的なファイルチェックに加えて、構築後の環境に対する Serverspecや、Karateを使ったAPIテストなどが有効そうです。

※Karateを使ったAPIテストについては、以前にshin0higuchiが書いた以下の記事も見てください
acro-engineer.hatenablog.com


もう一つは、「ロジックの正しさ」がありますね。

JavaPythonなど、Elasticsearchを使うアプリケーションのロジックはユニットテストをすることを考えますが、Logstashのconfはどうでしょうか?

書いてみたことがある方は分かると思いますが、Logstashのconf(つまりパイプライン)は、れっきとしたロジックであると言えます。
すなわち、入力(input)があり、処理(filter)があり、出力(output)があり、filter部分にロジックがあるわけです。

今回は、この「Logstash conf (の filter) をテストする」ことを考えてみたいと思います。

logstash-test-runner

Logstash の conf それ自体は、定義ファイルのような形で作成・デプロイするので、このファイルを単体で試験しようとする人は多くないようです。

しかしながら、少し検索してみたら、以下のような、node.js ベースのTest Runnerを作っている方がいました。

github.com

原理はシンプルで、以下の3種類

  1. 入力となるログデータ
  2. テストしたいconfファイル
  3. 出力として期待する正解データ

を1セットとして用意すると、実際に 2. の conf を実行して得られたデータと 3. の正解データを比較して合否を判定してくれるというものです。

Logstashの実行は、Elastic社の純正Dockerイメージを取得して行うため、実行環境にあらかじめLogstashをインストールしておく必要はありませんし、バージョンの切り替えも簡単に行えます。

テストの仕組みとしては手軽で面白そうです。これを実際に使ってみましょう。

環境の準備

実行する環境は、Linuxbash シェルスクリプトが動作する環境)にします。

今回は、Azure上にVM(OSはUbuntu 18.04.3 LTS)を立てましたが、以下の要件を満たせるLinux環境を作れるなら、オンプレでも問題ないと思います。

※logstash-test-runner のページから抜粋

  • NodeJS > v8
  • Docker
  • Bash > v4

なお、前述の通り、ツール動作時にElastic社のDockerイメージを取得するため、インターネットに接続できる環境にしてください。

今回、私が構築した環境は以下の通りです。
本当は、node.jsはv11とかを入れておくべきなのでしょうけれども、手抜きで済ませております。

$ node -v
v8.10.0

$ docker -v
Docker version 19.03.5, build 633a0ea838

$ bash --version
GNU bash, version 4.4.20(1)-release (x86_64-pc-linux-gnu)

早速動かしてみる

まずは、git を使って、テストツールをクローンします。

$ git clone https://github.com/agolo/logstash-test-runner.git
$ cd logstash-test-runner

デフォルトで用意されているテスト(__tests__)を実行してみます。

テストは、test.sh というスクリプトから実行します。(お試しで作ったスクリプトではありません)
test.shの第1引数にディレクトリを指定すると、その配下のディレクトリをテストケースとして認識するようです。

今回の環境では、dockerコマンドはrootで実行する必要があるため、test.shもsudoをつけて実行します。

また、実行の際には、明示的にbashで実行する必要がありました。(この点はディストリビューションにもよるでしょう)

$ sudo bash test.sh __tests__

f:id:acro-engineer:20191215070512p:plain

通りました。簡単ですね!
初回はDockerイメージを取得するのに少々時間がかかりますが、2回目以降は各テスト30~60秒で通ります。
(テストにかかる時間の半分ぐらいは、Logstashの起動時間ですが)

Logstashのバージョン指定

デフォルトではlogstashのバージョンが5.5.1なのですが、第2引数を指定することで変えることができます。

せっかくなので、最新バージョンである7.5で試してみましょう。

$ sudo bash test.sh __tests__ docker.elastic.co/logstash/logstash:7.5.0

f:id:acro-engineer:20191215070515p:plain

おや、mongoのテストが失敗しました。
差分が出ると、上記のような diff結果が表示されます。

どこから失敗するようになるのでしょうか?

ということで、それぞれのバージョンで試してみました。
(これがコマンドラインオプションだけで試せるのはとても便利ですね)

バージョン 結果
5.5.1 成功
6.0.0 成功
6.5.0 成功
6.8.0 成功
7.0.0 失敗
7.5.0 失敗

上の結果から、6.x → 7.0 にバージョンアップするときに Logstash の動作仕様が変わったということがわかります。

差分を修正する

前述の差分は、よく見ると mongo テストの出力データの先頭2行が入れ替わっていることがわかります。

本来は、「なぜ入れ替わったのか」の原因を特定し、Logstash conf を修正するなどして解決するべきですが、
今回の入れ替わりがなぜ発生するのかは、少し調べてみただけではわかりませんでした。

ということで、(本来は良くない対処ですが)正解データを修正して、テストが通るようになるか試してみましょう。

mongo テストの正解データは、 __tests__/mongo/output.log です
これを開いて編集します。

{"conn_type":"COMMAND","querytime_ms":109,"@message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","@timestamp":"2017-08-24T18:16:02.110Z","mongo_message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","loglevel":"I","@version":"1","host":"testing_host","context":"conn88987","message":"2017-08-24T18:16:02.110+0000 I COMMAND  [conn88987] command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command 109ms","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T18:16:02.110+0000"}
{"conn_type":"ASIO","message":"2017-08-24T21:55:01.204+0000 I ASIO     [NetworkInterfaceASIO-ShardRegistry-0] Successfully connected to mydatabase.com:27019, took 66ms (1 connections now open to mydatabase.com:27019)","tags":["_jsonparsefailure"],"querytime_ms":66,"@message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","@timestamp":"2017-08-24T21:55:01.204Z","mongo_message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","loglevel":"I","@version":"1","host":"testing_host","context":"NetworkInterfaceASIO-ShardRegistry-0","timestamp":"2017-08-24T21:55:01.204+0000","mongo_message2":" (1 connections now open to mydatabase.com:27019)"}
{"conn_type":"NETWORK","@message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","@timestamp":"2017-08-24T22:01:36.933Z","mongo_message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","loglevel":"I","@version":"1","host":"testing_host","context":"conn99615","message":"2017-08-24T22:01:36.933+0000 I NETWORK  [conn99615] received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T22:01:36.933+0000"}

これを、以下のように変えます。(つまり、COMMAND と ASIO の2行を入れ替えただけです)

{"conn_type":"ASIO","message":"2017-08-24T21:55:01.204+0000 I ASIO     [NetworkInterfaceASIO-ShardRegistry-0] Successfully connected to mydatabase.com:27019, took 66ms (1 connections now open to mydatabase.com:27019)","tags":["_jsonparsefailure"],"querytime_ms":66,"@message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","@timestamp":"2017-08-24T21:55:01.204Z","mongo_message":"Successfully connected to mydatabase.com:27019, took (1 connections now open to mydatabase.com:27019)","loglevel":"I","@version":"1","host":"testing_host","context":"NetworkInterfaceASIO-ShardRegistry-0","timestamp":"2017-08-24T21:55:01.204+0000","mongo_message2":" (1 connections now open to mydatabase.com:27019)"}
{"conn_type":"COMMAND","querytime_ms":109,"@message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","@timestamp":"2017-08-24T18:16:02.110Z","mongo_message":"command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command","loglevel":"I","@version":"1","host":"testing_host","context":"conn88987","message":"2017-08-24T18:16:02.110+0000 I COMMAND  [conn88987] command production.feeds command: find { find: \"feeds\", filter: { _id: \"abcdefg\" }, limit: 1, shardVersion: [ Timestamp 0|0, ObjectId('000000000000000000000000') ] } planSummary: IDHACK keysExamined:1 docsExamined:1 cursorExhausted:1 numYields:0 nreturned:1 reslen:2031 locks:{ Global: { acquireCount: { r: 2 }, acquireWaitCount: { r: 1 }, timeAcquiringMicros: { r: 108897 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_command 109ms","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T18:16:02.110+0000"}
{"conn_type":"NETWORK","@message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","@timestamp":"2017-08-24T22:01:36.933Z","mongo_message":"received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","loglevel":"I","@version":"1","host":"testing_host","context":"conn99615","message":"2017-08-24T22:01:36.933+0000 I NETWORK  [conn99615] received client metadata from 40.70.67.250:43270 conn99615: { driver: { name: \"NetworkInterfaceASIO-TaskExecutorPool-1\", version: \"3.4.7\" }, os: { type: \"Linux\", name: \"Ubuntu\", architecture: \"x86_64\", version: \"16.04\" } }","tags":["_jsonparsefailure"],"timestamp":"2017-08-24T22:01:36.933+0000"}

これで、もう一度実行してみましょう。

$ sudo bash test.sh __tests__ docker.elastic.co/logstash/logstash:7.5.0

f:id:acro-engineer:20191215070519p:plain

成功するようになりました。

まとめ

logstash-test-runner を用いることで、conf ファイルをリグレッションテストする仕組みが比較的簡単に作れることがわかりました。

このツールは、ツール出力を正解データと差分比較することで合否判定をするため、正解データを予め用意しておくことが必要になります。
通常、Logstashの出力で正解データを手動で作成して用意することは現実的ではありませんので、「一回実行してうまくいったログ」を正解データとして使用することになるでしょう。

そういった意味で、このツールは「リグレッションテスト」用途に向いていると言えるでしょう。
それでも、Logstash の conf に対して、変更による破壊が起きていないかを早い段階で見つけられるようになるので、安心感が得られると思いました。


さて、そうすると、CIを回したくなりますね。
このツールの結果をJenkinsなどでレポート表示するには、多少解析する必要がありそうです。

この辺りの仕組みが作れないか、いずれ検討してみたいと思います。


では。

Acroquest Technologyでは、キャリア採用を行っています。


  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長

 
少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。

世界初のElastic認定エンジニアと一緒に働きたい人Wanted! - Acroquest Technology株式会社のエンジニアの求人 - Wantedlywww.wantedly.com

全文検索で文書の新しさを考慮したスコアリング

皆さんこんにちは。@Ssk1029Takashiです。
この記事はElastic Stack (Elasticsearch) Advent Calendar 2019の13日目になります。
qiita.com

何か調べ物をしているとき、見つけた記事が古く使えないということが良くあります。
例えばElasticsearchのクエリを調べていたら、ver5時代の記事ですでに仕様が変わっていたりなど。。。
検索結果としては基本的には時系列が新しいものを優先して出してほしいことが多いです。

このように、検索システムでは基本的には新しい記事、なおかつ検索キーワードと関連度が高い記事を優先して出してほしいということがあります。
この時、単純に時系列でソートすると関連度を考慮できないため、検索スコアにいい感じに時系列情報を組み込む必要があります。
Elasticsearchでは、この問題をscript score queryを使って解決できます。

script score queryとは

Elasticsearch ver7から追加されたクエリで、scoreの値をpainless scriptで書くことができます。
www.elastic.co

例えば、以下のクエリで検索するとします。

GET /_search
{
    "query" : {
        "script_score" : {
            "query" : {
                "match": { "message": "elasticsearch" }
            },
            "script" : {
                "source" : "doc['likes'].value / 10 "
            }
        }
     }
}

このクエリでは、各検索結果のscoreはlikeフィールドの値を10で割った値になります。
このように検索クエリの中で、scoreの値をカスタマイズできるのがscript score queryです。

script score queryで時系列を考慮するには

script score queryでは、いくつかデフォルトで使用できる関数があるのですが、今回はその中のdecay関数を使用します。

decay関数の簡単な説明

decay関数とは名前の通り、特定の値に基づいて検索スコアを減衰させる関数です。
decay関数は適用できるデータ型によって3種類に分けられます。
1. 数値に基づきスコアを減衰させる関数
2. 地理的な距離に基づきスコアを減衰させる関数
3. 時系列に基づきスコアを減衰させる関数

また、それぞれのdecay関数には減衰値の計算方法が3種類(線形、ガウス分布正規分布)用意されています。
例えば、時系列に基づくdecay関数は以下の3つです。
1. decayDateLinear
2. decayDateExp
3. decayDateGauss

それぞれどのように値が減衰していくかのイメージが公式ドキュメントにあります。
https://www.elastic.co/guide/en/elasticsearch/reference/7.5/images/decay_2d.png
Function score query | Elasticsearch Reference [7.5] | Elastic
この中から、システム要件に合ったものを選びます。

実際のクエリは以下のような形になります。

{
  "query": {
    "script_score": {
      "query": {
        "match_all": {}
      },
      "script": {
        "source": "decayDateGauss(params.origin, params.scale, params.offset, params.decay, doc['timestamp'].value)",
        "params": {
          "origin": "2019-12-13T00:00:00Z",
          "scale": "60d",
          "offset": "0",
          "decay": 0.99
        }
      }
    }
  }
}

クエリ中で使用している引数は以下の4つになります。
origin: 減衰を計算する原点となる日時。この日時とドキュメントの日時がどれくらい離れているかでスコアが計算されます。
scale: 原点から距離を計算するときの単位。
offset: スコアを減衰させない範囲。
decay: どれくらいスコアを減衰させるか。

このようにscriptとして関数を呼び出すことで、scoreを調整することができます。

実際に試してみる。

データは以下の3つを投入します。

timestamp title content
2019-12-08 12:00:00 Micrometerで取得したデータをKibanaで可視化してみました Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!ということでMicrometer用のKibanaダッシュボードを作ってみました。
2018-12-20 12:00:00 ElasticsearchのRanking Evaluation APIについて整理してみた Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。Elasticsearchのバージョン6.2以降で利用することができます。
2018-05-08 12:00:00 Elasticsearchの圧縮方式の比較 Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。

まずは、普通に検索してみます。

GET datetime_docs/_search
{
  "query": {
    "multi_match": {
      "query": "elasticsearch",
      "fields": ["title", "content"]
    }
  }
}

結果

{
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.5543933,
        "_source" : {
          "title" : "Elasticsearchの圧縮方式の比較",
          "content" : """
  Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。
Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、
実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。
""",
          "timestamp" : "2018-05-08T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.46122766,
        "_source" : {
          "title" : "ElasticsearchのRanking Evaluation APIについて整理してみた",
          "content" : "  Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。\nElasticsearchのバージョン6.2以降で利用することができます。",
          "timestamp" : "2018-12-20T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.150402,
        "_source" : {
          "title" : "Micrometerで取得したデータをKibanaで可視化してみました",
          "content" : "  Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!\nということでMicrometer用のKibanaダッシュボードを作ってみました。",
          "timestamp" : "2019-12-08T12:00:00Z"
        }
      }

結果としては、キーワードが頻繁に出てくるドキュメントが上位に来ていますが、古いものが上位に来てしまっています。
これを、新しいドキュメントがより上位に来やすくします。

GET datetime_docs/_search
{
  "query": {
    "script_score": {
      "query": {
        "multi_match": {
          "query": "elasticsearch",
          "fields": ["title","content"]
        }
      },
      "script": {
        "source": "_score + decayDateGauss(params.origin, params.scale, params.offset, params.decay, doc['timestamp'].value)",
        "params": {
          "origin": "2019-12-13T00:00:00Z",
          "scale": "30d",
          "offset": "0",
          "decay": 0.99
        }
      }
    }
  }
}

script内の_score変数にはquery内で実行した単語検索によるスコアが入ります。
つまり、このクエリでは単語検索のスコア+時間で減衰したスコアの値を最終的なスコアにしています。
こちらの結果は以下のようになります。

      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.1501759,
        "_source" : {
          "title" : "Micrometerで取得したデータをKibanaで可視化してみました",
          "content" : "  Elastic Stackを活用しているAcroquestとしてはせっかくElasticsearchにも保存できるのにこれはもったいない…なら、うちで作るしかない!\nということでMicrometer用のKibanaダッシュボードを作ってみました。",
          "timestamp" : "2019-12-08T12:00:00Z"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "2",
        "_score" : 0.7012034,
        "_source" : {
          "title" : "ElasticsearchのRanking Evaluation APIについて整理してみた",
          "content" : "  Ranking Evaluation APIは、検索クエリに対する検索結果の妥当性を評価するためのAPIです。\nElasticsearchのバージョン6.2以降で利用することができます。",
          "timestamp" : "2018-12-20T12:00:00"
        }
      },
      {
        "_index" : "datetime_docs",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 0.57671785,
        "_source" : {
          "title" : "Elasticsearchの圧縮方式の比較",
          "content" : """
  Elasticsearchを使っているとストレージの使用量を節約したいと思う方は多いのではないでしょうか。
Elasticsearchはデータを格納するときにデフォルトでLZ4という圧縮方式でデータ圧縮を行っていますが、
実はLZ4よりも圧縮率の高いbest_compressionという圧縮方式を利用することもできます。
""",
          "timestamp" : "2018-05-08T12:00:00"
        }
      }

新しいドキュメントを上位に出すことができました。
あまりにも新しいものが上位に来すぎてしまう場合には、scaleやdecayの値を調整することで、検索結果をチューニングすることができます。

まとめ

script score queryのdecay関数を使用して、文書の新しさを考慮した検索を簡単に実現しました。
クエリで簡単にスコアを調整できるのは、検索システムの運用上非常に便利ですね。
script score queryにはほかにも便利な関数がいくつか実装されているので、ぜひ活用してみてください。
それではまた。

明日は、mac_akibaさんの記事になります。ぜひお楽しみに!