はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Elasticsearch のMore Like This Queryを使ってみた(グルメ的に似たエリアを検索)

Elasticsearch のMore Like This Query(以下MLT)を使ってみました。

More Like This Query(MLT)について

MLTは名前の通り、あるドキュメントに似たドキュメントを探す検索クエリです。

f:id:azotar:20200729121846p:plain

ドキュメントそのもののレコメンドに使ったり、あるプロフィールのユーザーと似たユーザーやあるいはモデルユーザーを発見するといった、if文を積み上げた判定ロジックでは実現が難しいようなマッチングに使えそうです。

また、やや邪道かもしれませんが、あらかじめピックアップしたドキュメントAとドキュメントBの類似度を測るカジュアルな方式としても使えそうですね。良い例えではないかもしれませんが、模範回答との大雑把な比較だとか、NGワード集に設定されている単語が使われすぎていないかといった一次判定に使えるような気がします。

MLTの類似度の考え方(の雑な説明)

じゃあ、何を持って似ているのかといういうと、公式サイトにもそれらしきことが書いてありますが、土台が全文検索エンジンならではということで、tf-idfとかterm_vectorという単語の出現頻度に関する指標をよろしく用いているようです。

あるドキュメントAがあって、AとA以外の複数のドキュメントの集合Sのうち、SのうちAに最も類似するドキュメントはAが選ばれることになりますが、これはAに出現する単語とそれらの単語の頻度などが比較元のAとSの中に紛れているAとで同じになる/なってしかるべきという論法でしょうか。

単語のカウントベースですので、ディープラーニングなどの手法に比べると、ヒトでは気づかなかった思わぬ類似ドキュメントを発見するということはまれでその点では面白みにかけます。

一方、意外性は欠けるものの、類似と見なされたAとBというドキュメントの単語の文字面は感覚的には近いものになる傾向があるといえそうです。というかアナライズにより正規化・標準化はされるものの、単語を数えているのでそうなっちゃいます。

よって、この類の話でありがちな(揉めがちな)、なんでAとBが似ていると判定されたのか?という説明は比較的楽ですね。

Elasticsearch MLT での実際の検索方法

検索方法としては大きく分けて次のような方式での問い合わせが可能です。(ver 7.x)

  • (A)ドキュメントIDを指定して、このドキュメントに似たドキュメントを検索
  • (B)フリーテキストを指定して、そのフリーテキストに似たドキュメントを検索

検索結果は、類似度が高い順にスコアリングされて戻ってきます。

実際のシンタックスは公式に書いてあるとおりです。

www.elastic.co

シンタックス自体は説明しませんが、あとで、前述の(A)でのサンプル例を示します。

なお、この記事のまとめにあたって、Elasticsearch ver 6.8 で確認していますが、難しいトリックなどは利用していないので、7.x でも動作すると思います。

また、次のブログで、主なパラメータが紹介されています。 私自身は公式の英語を読みこむのがめんどくさい時に参照させていただいています。

qiita.com

[補足] MLTはElasticsearch 検索DSLの一種

MLTのクエリは、Elasticsearchの各種検索DSLの一種ですので、boolクエリ(複数の検索DSLのANDやORの組み合わせ)など複合クエリにぶら下げることが可能です。

つまり、日本とMLBの野球選手のうち、MLB所属の選手に限って、選手Aと似た別の選手を検索するといった用法も可能です。

MLT自体のチューニングパラメータは

  • max_query_terms
  • min_term_freq
  • min_doc_freq
  • max_doc_freq
  • min_word_length
  • max_word_length

のように、TF-IDF の世界観を感じさせる各種パラメータがありますが、私自身の経験則としては、MLTそのものチューニングよりも、他の絞込み条件とのAND検索にしてしまう方法の方が、類似文書検索として期待した結果を得やすい/定性的な仕様・要件としてコントロールしやすいのではと感じています。


★実際にやってみる

前説

このブログの過去記事で、Livedoorレストランデータを取り込んでていますので、このデータを派生させて、エリアごとの特徴的なレストランカテゴリデータを用意します。このエリア特徴カテゴリデータを用いて、あるエリアAと似た全国のエリアが存在するかという検索を行なってみます。

例えば、

銀座エリアの類似エリアを検索すると、神楽坂や六本木エリアが類似エリアとして抽出されるか

というような実験です。

レストランデータにからめるなら、店Aの類似の店を探すのがMLT界隈の王道かと思いますが、なんとなく記事としては面白くないので、「似たエリア」を探すというストーリーにしています。

なお、例自体はシンプルなのですが、取り上げたサンプルは、他記事の手順の積み上げとなりますので、最初から手順をなぞると結構な分量になってしまいますが、ご容赦ください*1

手順

(1)検索データ作成

次の記事の手順にそって、Livedoorレストランデータを取り込んでください。

itdepends.hateblo.jp

これにより、レストランデータが、ldgroumetというインデックスに取り込まれることになります。

続いて、次のsignificant_termsクエリで、エリアとそのエリアにおいて特徴的なレストランカテゴリ情報を抜き出します。

POST /ldgourmet/_search?filter_path=agg*
{
  "aggs": {
    "a1": {
      "terms": {
        "field": "pref.raw",
        "size": 47
      },
      "aggs": {
        "a2": {
          "terms": {
            "field": "area_name.raw",
            "size": 100
          },
          "aggs": {
            "a3": {
              "significant_terms": {
                "field": "cates.raw",
                "size": 50,
                "min_doc_count": 5,
                "chi_square": {}
              }
            }
          }
        }
      }
    }
  }
}

// ↓ 戻り値イメージ

{
  "aggregations" : {
    "a1" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 39,
      "buckets" : [
        {
          "key" : "13__東京都",
          "doc_count" : 64565,
          "a2" : {
            "doc_count_error_upper_bound" : 0,
            "sum_other_doc_count" : 0,
            "buckets" : [
              {
                "key" : "多摩(府中・立川・八王子)",
                "doc_count" : 4554,
                "a3" : {
                  "doc_count" : 4554,
                  "bg_count" : 214237,
                  "buckets" : [
                    {
                      "key" : "醤油ラーメン",
                      "doc_count" : 218,
                      "score" : 266.40500798796563,
                      "bg_count" : 3635
                    },
                    {
                      "key" : "ラーメン",
                      "doc_count" : 657,
                      "score" : 217.22213013602024,
                      "bg_count" : 18050
                    },
                    {
                      "key" : "つけ麺",
                      "doc_count" : 170,
                      "score" : 202.84038655009383,
                      "bg_count" : 2862
                    },

                    以下省略

※ あたかもMLTを実行するには、significant_terms*2前捌きが必要かのように書いていますが、実際はそうではありません。 しかし、ここでは、ドキュメントの特徴的な情報をsignificant_termsで抜き出しておけば、特徴が際立ったデータ間どおしで類似度をMLTで判定すれば、よりハッキリMLTの動作が分かりやすいだろうという意図と単に手元の環境のデータ容量の都合だったりします。

戻り値のJSONテキストを、foo.txtとして保存しておきます。

(2)検索用データインポート

前項で取得したグルメ特徴データ(foo.txt)をMLT用にインデックスにインポートします。

(2-1) mapping設定

まず、インポートにあたり、前もって、MLTの対象とするフィールドを意識して、Elasticsearchに次のインデックスのmapping設定を行います。

PUT mlt_example2?include_type_name=false
{
  "mappings": {
    "properties": {
      "bkts": {
        "type": "text",
        "term_vector": "with_positions_offsets",
        "store":true,
        "analyzer": "whitespace"
      }
    }
  }
}

※1 MLTの対象フィールドは、keywordかtextかでいうと、アナライザーが実行されるtextが対象になります。これは、MLTが「類似文書検索」という名のとおり、フリーテキストAとそれに類似のフリーテキストを検索するという目的からすると自然なのですが、ここでは、ある種のチューニング効果を狙うのと、加工済みデータから作成したデータというところの都合もあり、フリーテキストではなく、単語の羅列になっています。

■ XXエリアのグルメ特徴データ(特徴カテゴリ一覧情報)

茶店 カフェ・喫茶 定食・食堂 お好み焼き その他郷土料理 お好み・もんじゃ・たこ焼き・そばめし 寿司 郷土料理 その他 洋食 焼肉 洋食・欧風料理

※2 この他、term_vectorやstoreなどいろいろポイントがあるのですが、この記事のストーリー独特かもしれない点だけ補足すると、今回取り込むデータは上記のようなsignificant_termsによる標準化されたワードの羅列なので、ここのワードの粒度は前処理で洗練されている前提で「whitespace」でanalyzeすることにしています。

(2-2) バルクロード

続いて、次のPythonスクリプトでグルメ特徴データをインデックス(mlt_example2)にインポートします。

他の用途のためのものを流用したので、今回は必要のない中途半端な汎用ロジックになっていて、その分冗長になっていますのでご容赦ください。

import glob
import pandas as pd
import json
import sys
import os

AGGS = "aggregations"
BKTS = "buckets"
aNames = ['a1','a2','a3']


def myAggs4DF(result):
    def _outerBuckets(result):
        return  result[AGGS][aNames[0]][BKTS]

    def _getInnerBuckets(_bucket, _inner_aNameNo):
            if aNames[_inner_aNameNo] in _bucket:
                return _bucket[aNames[_inner_aNameNo]][BKTS]
            return []            
            
    _bs = _outerBuckets(result)
        
    # aggs階層はa1、a2を基本として想定しているが、a3まで存在するなら、a2、a3でデータを構成するようにデータを一段掘り下げる
    aX = 1 
    if aNames[aX + 1] in _getInnerBuckets(_bs[0],aX)[0]:
        __bs = []
        for _b in _bs:
            __bs.extend( _getInnerBuckets(_b,aX))
        _bs = __bs
    
        aX += 1
    
    aggs = []
    
    for _b in _bs:
        k = _b['key']
        dc = _b['doc_count']
        bcks = list(_getInnerBuckets(_b, aX))
        innerBs = [ ib['key'] for ib in bcks]
        少数dc閾値 = 30
        戻り最大件数 = 12 if ( 少数dc閾値 < dc ) else 2
        aggs.append([k, dc, ' '.join(innerBs[:戻り最大件数])])
    
    return aggs,['k','dc','bkts']
    
files=glob.glob('foo.txt')
_f=files[0]
_r = open(_f, 'r').read()
aggs = myAggs4DF(json.loads(_r))
aDF = pd.DataFrame(aggs[0], columns=aggs[1])
aDF.to_csv(sys.stderr)
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
endpoint = 'http://localhost:9200'
indexname = 'mlt_example2'
es = Elasticsearch(endpoint)

aDF['_index'] = indexname
aDF['_type'] = '_doc'
bulk(client=es, actions=aDF.to_dict(orient='records'))

(3)MLT 実行

ここまでの手順で、mlt_example2 というインデックスに、エリアとそのエリアにおいて特徴的なレストランカテゴリの一覧情報が入りました。

随分前置きが長くなりましたが、広島市エリアとグルメ的に似たエリアをMLT検索する」ということをやってみたいと思います。

なお、広島市エリアは、広島風お好み焼きや(かき料理などに関連すると思われる)和食や創作料理系のカテゴリに特徴・特色ありというデータになっています。

よって、

広島県内の他エリアや関西の粉物で有名なエリアがヒットするんじゃないか

という経験的期待のもとで進めていきます。

(3-1) 広島市エリアのIDを確認

今回、インポートしたデータではこんな感じでした。

      {
        "_id" : "f8E1mHMBqLsHNliUmwDU",
        "_source" : {
          "k" : "広島市",
          "dc" : 2008,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      }
(3-2) 広島市エリアを検索キーにしたMLT

MLTのクエリはこんな感じです。「広島市エリア」のドキュメントIDを指定して、これに似たドキュメントを検索するというクエリになっています。

※「bkts」というフィールドを「類似」スコアリングに使います。 ※ "include": true を指定して、「広島市エリア」自体も検索結果の戻り値に加わるようにしています。

GET mlt_example2/_search
{
  "size":10,
  "query": {
    "bool": {
      "must":[
        {
        "more_like_this": {
          "fields": [
            "bkts"
          ],
          "like": [
            {
              "_index": "mlt_example2",
              "_id": "f8E1mHMBqLsHNliUmwDU"
            }
          ],
          "min_term_freq": 1,
          "min_doc_freq":2,
          "max_query_terms": 25,
          "include": true
        }
      }
      ]
    }
  }
}

↓ 検索結果(整形済み)

{
  "hits" : {
    "hits" : [
      {
        "_score" : 28.441929,
        "_source" : {
          "k" : "広島市",
          "dc" : 2008,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      },
      {
        "_score" : 12.378284,
        "_source" : {
          "k" : "福山、尾道、備後",
          "dc" : 615,
          "bkts" : "広島風お好み焼き 鉄板焼き お好み・もんじゃ・たこ焼き・そばめし 欧風料理 アイスクリーム カニ モツ鍋 串揚げ・串かつ お好み焼き もんじゃ 焼き鳥 甘味処"
        }
      },
      {
        "_score" : 11.468344,
        "_source" : {
          "k" : "名古屋駅",
          "dc" : 741,
          "bkts" : "和食その他 その他和食 炉端焼き カニ モツ鍋 和食 甘味処 西洋料理その他 串揚げ・串かつ うなぎ うなぎ・どじょう 創作料理"
        }
      },
      {
        "_score" : 11.249291,
        "_source" : {
          "k" : "高松市",
          "bkts" : "讃岐うどん そば・うどん類 和食 うどん 創作料理 和食その他 その他和食 カフェ 紅茶専門店 カニ ホルモン焼き おでん"
        }
      },
      {
        "_score" : 10.131022,
        "_source" : {
          "k" : "松山市",
          "bkts" : "カフェ お好み焼き カフェ・喫茶 お好み・もんじゃ・たこ焼き・そばめし ファーストフード 大阪風お好み焼き 広島風お好み焼き 鉄板焼き 居酒屋 クレープ 洋食 ファミリーレストラン"
        }
      },
      {
        "_score" : 9.382338,
        "_source" : {
          "k" : "徳島市",
          "bkts" : "創作料理 居酒屋 和食その他 居酒屋・ダイニングバー ジンギスカン その他 その他和食 串揚げ・串かつ うどん 欧風料理 カニ 洋食"
        }
      },
      {
        "_score" : 9.360852,
        "_source" : {
          "k" : "岡山市",
          "bkts" : "創作料理 ダイニングバー 割烹 和食その他 懐石・精進・料亭・割烹 洋食 居酒屋・ダイニングバー 居酒屋 その他和食 その他 炉端焼き カニ"
        }
      },
      {
        "_id" : "RsE1mHMBqLsHNliUmwHV",
        "_score" : 8.5516815,
        "_source" : {
          "k" : "高知市",
          "bkts" : "その他郷土料理 居酒屋 郷土料理 居酒屋・ダイニングバー 和食その他 創作料理 その他和食 その他 パーティー・宴会 無国籍料理 和食 洋食"
        }
      },
      {
        "_score" : 8.020034,
        "_source" : {
          "k" : "栄・新栄",
          "bkts" : "バー ワインバー 和食その他 その他和食 その他西洋各国料理 パブ 西洋料理 西洋料理その他 ジンギスカン フルーツパーラー スペイン料理 韓国料理"
        }
      },
      {
        "_score" : 7.942418,
        "_source" : {
          "k" : "河原町、四条大宮、御池、二条、京都",
          "bkts" : "京料理 懐石・精進・料亭・割烹 懐石料理 創作料理 割烹 甘味処 会席料理 甘味・パーラー 和菓子 お好み焼き 湯葉 鉄板焼き"
        }
      }
    ]
  }
}

定性的な結果としては、今回設置したデータの範囲では、確かに似ていると呼べる地域がヒットしているように思われます。

実のところこの記事での味付けであるsignificant_termsでの前捌きがあるため、純粋なMLTの実力が少し隠れてしまったところもありますが、ある程度は納得感がある結果だと思います。

一方で、実体験からくる主観を多少織り込んで見てみると、広島風お好み焼きと(関西風もしくは広義の意味での)お好み焼きの違いの差が思ったほど出なかったため、広島県内の他の地域は「類似」と見なされたものの、トップヒットではなかったというのは気になるところです。

例えば、お好み焼き宗派によっては、互いに一緒にしてほしくない(これらのエリアは似ていない)という意見がありそうです。

また、"名古屋駅"、"高松市"、"徳島市"あたりは、実際にそれらの地域を訪問した方にとっては(グルメ以外も含めた街の雰囲気・空気感からして)分からんでもないもの、"栄・新栄"エリアや "河原町四条大宮、御池、二条、京都"エリアは、"広島市"エリアと類似と言われた時に、これらより類似度が高いエリアがあっても良いのではという気もしないでもないです。

このような結果になった理由としては、(お好み焼宗教戦争は別の話に飛び火するのでそれ自体は深追いしませんが、)今回のMLTの比較条件に用いた各エリアの特徴語はsignificant_termsを使ってあらかじめ抽出したものというところが、逆にMLTのTF-IDFでの類似度比較に作用しなかったというのが理由の一つと思われます。

類似度を効率よく判定するために、今回はあらかじめsignificant_termsで特徴抽出しています。本来significant_termsではスコア順でより特徴的なものからの順序になりますが、一方で、類似ドキュメント検索用のインデックスに登録するにあたっては、今回は作業都合で、スコアが高いものも低いものも、1回の出現回数で箇条書きのフリーテキストの体裁で登録しているので、より特徴を表したグルメカテゴリとそうでないものについてある種メリハリがつかない状態になっていたのかなというところです。


現場エンジニアリング視点(いかに取り繕うか)での考察*3

ということで、MLTってだいたい良い感じなのではというインプレッションに至ったのですが、とちょっとした後悔として

広島市の類似エリア検索では、 "福山、尾道、備後"エリアや"安芸、廿日市広島県西部"エリア、"備北"エリアが 上位ヒットして欲しかったという (ヒットしたものもあったが、上位ではなかった)

というものが残ります。

もちろん、そのようになりがちな前処理データを用いたというところの副作用の可能性も大きく、MLTさんからみるとフェアではないという反論もあるところですが...

significant_termsをからめず、より素直に、MLTのパワーをアテにして、大元のレストランデータの地域ごとののべカテゴリ一覧情報を集計・束にしたフィールドを持つインデックスを作成し、これに対してMLTをしてみればよかったかもしれません。

あるいは、significant_termsの前捌きありの方向性*4で推し進めるとして、「(ある方面の)類似感の納得度を高める」とすると次のような工夫が考えられるのかなと思いました。

チューニングの方向性

  1. boolクエリ(のmust)として、インプットドキュメント側の上位ワードによるmatchやtermクエリとそもそものMLTのAND検索にする。
  2. 上位のワードを、配列に複数回挿入して、term_freqを増やし「重み」に見せかける。
  3. ワードを桁数少なめのハッシュ値やコード値に変換して、配列に複数回...(以下、前項と同様)。
  4. ベクトル表現にする。※これをやるなら最初からそうすべきではと思いつつ、アンサンブル学習的な美点はあるかも。
  5. 上位のワードは別フィールドにも再掲して、MLTの追加対象フィールドとする。(more_like_this.fields)

2、3などは、今回の作業都合で失われてしまった頻度による特徴をやや乱暴な方法で復活させる方法です。 挙げてはみたもののの、TF-IDFにおけるレア度による「貴重なワードは出現頻度が小さくても重みは大きい...」といった本来のメリットを阻害する、別の不確定要素が入り込みそうですね。

実際は1(および1の変種としての上位ワードが含まれるほど加点する絞込み検索DSLを設ける)や5が良いかな...

ということで、1の考え方にそって、検索クエリを見直してみます。 (MLTの検索結果を広島市エリアの「広島風お好み焼き」を特徴データとして含むようなエリアに絞り込む。)

GET mlt_example2/_search
{
  "size":10,
  "query": {
    "bool": {
      "must":[
        {
        "more_like_this": {
          "fields": [
            "bkts"
          ],
          "like": [
            {
              "_index": "mlt_example2",
              "_id": "f8E1mHMBqLsHNliUmwDU"
            }
          ],
          "min_term_freq": 1,
          "min_doc_freq":2,
          "max_query_terms": 25,
          "include": true
        }
      },
      {
        "match": {
          "bkts": "広島風お好み焼き"
        }
      }
      ]
    }
  }
}

↓ 結果

{
  "hits" : {
    "hits" : [
      {
        "_score" : 31.89145,
        "_source" : {
          "k" : "広島市",
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 鉄板焼き お好み焼き 和食 創作料理 和食その他 その他和食 チーズ料理 スペイン料理 南欧・地中海料理 カニ"
        }
      },
      {
        "_score" : 15.278094,
        "_source" : {
          "k" : "福山、尾道、備後",
          "bkts" : "広島風お好み焼き 鉄板焼き お好み・もんじゃ・たこ焼き・そばめし 欧風料理 アイスクリーム カニ モツ鍋 串揚げ・串かつ お好み焼き もんじゃ 焼き鳥 甘味処"
        }
      },
      {
        "_score" : 13.580544,
        "_source" : {
          "k" : "松山市",
          "bkts" : "カフェ お好み焼き カフェ・喫茶 お好み・もんじゃ・たこ焼き・そばめし ファーストフード 大阪風お好み焼き 広島風お好み焼き 鉄板焼き 居酒屋 クレープ 洋食 ファミリーレストラン"
        }
      },
      {
        "_score" : 10.363851,
        "_source" : {
          "k" : "備北",
          "dc" : 84,
          "bkts" : "広島風お好み焼き お好み・もんじゃ・たこ焼き・そばめし 喫茶店 お好み焼き カフェ・喫茶 焼肉 中華料理その他 その他中華料理 カフェ 洋食 中華料理 洋食・欧風料理"
        }
      },
      {
        "_score" : 9.34375,
        "_source" : {
          "k" : "安芸、廿日市、広島県西部",
          "bkts" : "広島風お好み焼き ファミリーレストラン お好み・もんじゃ・たこ焼き・そばめし 定食・食堂 会席料理 回転寿司 うどん お好み焼き 洋食 ステーキハウス 和食 カフェ"
        }
      }
    ]
  }
}

広島の地名と瀬戸内海を挟んで向かいのグルメ文化圏的に近いと思われる松山市が入り込んだランキングになりました。

臭いものに蓋をした感はありますが、なかなかですね。

1や5は、賛否両論ありそうな「類似」判定例を間引くことができますので、オトナのエンジニアリング世界では欠かせない、「ご説明の都合」がつきやすいです。

もちろん、「気づきや意外な発見」のようなものは失われる面もありますが...

殺伐とした世界(?)で生き残っていくには、このようなケンカ殺法もうまく活用していきたいところです。

以上

*1:せめて本例に絞った直前の生データを公開できれば良いのですが、思わぬところで利用規約に反しても困るので、今回は控えます

*2:significant_terms自体の例は、

itdepends.hateblo.jp

でご紹介しています。

*3:「言い訳」ですね...

*4:significant_termsを使ってある種タグクラウド相当の情報が得られることで、MLTの類似度判定が実際に腑に落ちるかを感覚的に確かめやすいという検討の進め方における隠れたメリットがあります。