はてだBlog(仮称)

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

Elasticsearchのひらがなでの検索時のトリックについて雑談

はじめに

Elasticsearch(kuromoji)では、アナライザーに「kuromoji_readingform」というものがあり、これを使うと「読み」に関して、表記揺れや曖昧検索相当に対応できます。

ただし、この「読み」部分については、実は、kuromojiの形態素解析(分かち書き/token化)とセットになる話なので、一番ベーシックなアナライズの設定組み合わせの範囲では実は次のようなことが発生します。


「渡辺」「渡邊」のような「端」「橋」のような、(そのようになるように検索クエリを用いれば)期待どおり互いに検索時にヒットする

一方で、

ひらがな「わたなべ」で、漢字の「渡辺」や「渡邊」を(ただしく読めていると思われるにもかかわらず)これらをヒットさせられない


これは、「読み」は当てられているものの、形態素解析トークン化されたものに対して、転置インデックスの各エントリに対して当てはまるかどうかで、そのドキュメントがマッチしたかどうかを判定するためです。

f:id:azotar:20200121235359p:plain

あたったり当たらなかったり

前項で述べた件のElasticsearch(ここでは、Elasticsearch 6.8 で確認。)の例を示します。

◆ 比較的ベーシック(?)なreadingformをからめたanalyzeの設定

PUT tmpx
{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                }
            },
            "analyzer": {
                "my_ja-readingform_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "hiragana_2_katakana"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                }
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "keyword",
                            "fields": {
                                "rf": {
                                    "type": "text",
                                    "analyzer": "my_ja-readingform_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

filterのkuromoji_readingformで読み仮名を付与している。

◆データのインポート

POST tmpx/_doc/
{  "A":"渡邊"}

◆検索例

「渡辺」で検索

POST tmpx/_search
{
  "query": {"match": {
    "A.rf": {
      "query": "渡辺",
      "operator": "and"
    }}   
  }
}

↓ 検索結果(渡邊がヒットする)


{
  "hits" : {
    "total" : 1,
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "tmpx",
        "_type" : "_doc",
        "_id" : "r7cYyG8BDQCF7SaoZSpT",
        "_score" : 0.2876821,
        "_source" : {
          "A" : "渡邊"
        }
      }
    ]
  }
}  


「わたなべ」で検索

POST tmpx/_search
{
  "query": {"match": {
    "A.rf": {
      "query": "わたなべ",
      "operator": "and"
    }}   
  }
}

↓ ヒットしない

{
    "hits" : {
    "total" : 0,
    "max_score" : null,
    "hits" : [ ]
  }
}

◆わたなべ・渡辺・渡邊 のアナライズの実績

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": "渡邊"
}

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": "渡辺"
}

どちらも
{
  "tokens" : [
    {
      "token" : "ワタナベ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

とトークン化される。
...なのでこれらは互いにヒットする。

では、

POST an/_analyze
{
  "analyzer": "my_ja-readingform_anlz",
  "text": " わたなべ"
}

だが、

次のようにトークン化される(4トークンに分割)。

{
  "tokens" : [
    {
      "token" : "ワ",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "タ",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "ナベ",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    }
  ]
}

漢字は、ひらがなに比べると文字数の割に情報量が多いため、形態素解析がヒトの目で見たのと同じで自然になる傾向があるが、 ひらがなは分析の手がかりが少なく、ひらがなの並びの中になんらか単語を見出した単位で分割される

...と言えそうです。

まあ、形態素解析の仕組みからいうと当たり前かもしれませんが、あらためて実感されます。

テーマの絞り込み

この類は問題はノイズとのトレードオフとの戦いです。

ですが、この記事ではそこは問わず、

「ひらがな」で「漢字」をヒットさせるには、

ということでシンプルに考えます。

また、「漢字」の方は、ある程度、辞書に載っている意味を持つ(≒ よみがなが決まる)ものだとします。そのような検索ケースを念頭において、そのようなパターンにまずはうまく行くような方法を考えます。

この他、簡単のため「ひらがな」および「漢字」の側ともに、複数の単語の組み合わせではないようなものを考えます。

そこそこうまく行く方法案

ここでは次のように考えます。

着目点

  1. readingformの結果、文字面としては合致してもトークン化された場合はマッチしない。(事実)
  2. それでも、漢字の方はある程度自然にトークン化されている。(おそらく多くの場合にあてはまる事実)
  3. 複合語だとより複雑になるが、今回の考察の検索モデルでは「漢字」の側は、1トークンとなる。
  4. 一方、ひらがなの方は、かなりきまぐれだが、「漢字」側のトークン化されているものよりは、「ひらがな」側の方が1トークンあたりの文字数が少ない傾向がある。(おそらく多くの場合にあてはまる事実)

つまり、どうやってやるかはともかく、入力の「ひらがな」の前方一致の文字列が、「漢字」側のトークンに(できれば前方一致の形で)出現するかという検索方法であれば、 どうにか「ヒットさせる」ことができそうです。

Elasticsearchのアナライザー、検索クエリ(ひらがな→漢字)

もったいぶりましたが、形態素解析でreadingformを使いつつ、filterでも通常のN-GramとEdge N-Gramが利用できるのでこれをつかいます。

日本語環境周辺だと、分かち書き(トークナイズ)は、形態素解析 vs N-Gramというイメージから、N-Gram系の処理は、トーカナイザーでしか利用できないのかと思いがちですが*1N-Gram系はfilterにも対応できるのでした。

www.elastic.co

www.elastic.co

つまり、kuromojiでトークン化した上で、それぞれのトークンの読みをあてたのち、それを部分一致させるように分割できます。

この方式を図解すると、

f:id:azotar:20200121235758p:plain

とした転置インデックスが作られます。ここで、「渡邊」というワードに対して、「わ」〜「わたなべ」というワードでヒットさせられる土台ができました。

加えて、(もっと高度な方法も考えられるかもしれませんが、)入力側の「ひらがな」はカタカナに変換するものの、下手なトークン化は行わず、入力された文字を転置インデックスにそのまま当てに行くことにします。

これにより、何がヒットして何がヒットしないのかをイメージしやすくします。

具体的には、先の転置インデックスの例を再掲するとともに、次の図のようなものを実現します。

f:id:azotar:20200121235836p:plain

ということで、この設定がこちら。

ひらがな→漢字検索用のmapping/setting例

{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                }
            },
            "analyzer": {
                "my_ja-default_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "kuromoji_iteration_mark",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_baseform",
                        "kuromoji_part_of_speech",
                        "ja_stop",
                        "lowercase",
                        "kuromoji_number",
                        "kuromoji_stemmer"
                    ]
                },
                "my_ja-readingform_x_e-ngram_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "lowercase",
                        "hiragana_2_katakana",
                        "e_ngram_filter"
                    ]
                },
                "my_almost_noop": {
                    "type": "custom",
                    "tokenizer": "keyword",
                    "filter": [
                        "hiragana_2_katakana"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                },
                "e_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 10
                }
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "my_ja-default_anlz",
                            "fielddata": true,
                            "store": true,
                            "fields": {
                                "rf_eng": {
                                    "type": "text",
                                    "analyzer": "my_ja-readingform_x_e-ngram_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

my_ja-readkingform_x_e-ngram_anlz が冒頭の例と比べると追加になっています。

また、my_almost_noopというアナライザーですが、トークン化は行わず、カナに変換するだけのアナライザーです。後述の検索クエリでは、「analyzer」プロパティにこれを指定し、検索語自体は「カナ」に置き換えした1ワードで転置インデックスを検索するようにします。

ひらがな→漢字検索用の検索クエリの例

POST an/_search
{
  "query": {"match": {
    "A.rf_eng": {
      "query": "わたなべ",
      "operator": "and",
      "analyzer": "my_almost_noop"
    }}   
  }
}

あるいは

POST an/_search
{
  "query": {"match": {
    "A.rf_eng": {
      "query": "わたな",
      "operator": "and",
      "analyzer": "my_almost_noop"
    }}   
  }
}

↓ 検索結果

  "hits" : {
    "total" : 1,
    "max_score" : 1.0166159,
    "hits" : [
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "trctyG8BDQCF7SaoJCqM",
        "_score" : 1.0166159,
        "_source" : {
          "A" : "渡邊"
        }
      }
    ]

無事、ヒットするようになりました。

まとめ

ワークアラウンドというレベルですが、あらかじめ読み仮名のデータを用意することなく、アナライザーのトリックの重ねがけにより、「よみがなのひらがな」→「漢字」での検索ができるような例を示してみました。

実のところ、setting/mappingの紹介という側面もあり記事を長文化したくなかったため(それでも長くなってしまいましたが)、その分だけ検索モデルをシンプルにしたから、うまく行ったという面もあります。

例えば、本来は「複合語」、特に「かなと漢字の複合語」、スペース区切りの複数ワードといった検索語、およびドキュメントデータの組み合わせの時にどこまでうまく機能させられるかというより難しい話があります。

また、今回は、ひとまずあたらないよりは当てる方法をという目的で話を進めましたが、おそらくこの方式によってノイズが増える傾向があるので、形態素でシンプルに検索させた場合に比べ、この例でのマッチングは加点を低めにするといったスコアの工夫も必要でしょう。

いや、そもそも、一部のオートコンプリートやサジェストの例以外で、「ひらがな」→「漢字」の検索を充実させるところに力をかけるかという話も(それを言ってはこの記事の自己否定ですが)あるにはあります。

この手の例についてはまた機会があれば(逃げ足の音)。

ややテーマがすりかわりましたがつ続きをかきました。

itdepends.hateblo.jp

*1:というか私は比較的最近までそう思っていました。

Elasticsearchで読みの頭文字で並び替える怠惰な方法の例(just idea)

1. はじめに

この記事は、ある検索系の問題設定において、できるだけElasticsearchの機能に閉じて*1手軽にやれるかどうか、という、よくある100本ノック風のチャレンジ(1本だけですが)のメモです。

図らずも、Elasticsearchのアナライズの解説っぽいところにもなったので記事にしてみました。

問題設定

それなりの数のドキュメント群があります。 ここで、各ドキュメントの題名(漢字中心のもの)を(漢字を含めた文字セットの文字コードではなくて)読み仮名順に並べてみる...とします。 ただし、ここで、題名の読み仮名情報は持っていないとします。

f:id:azotar:20200119232229p:plain

対応方法キーワード

上記の問題にあたっての、おおよその登場人物/キーワードです。

  1. kuromojiプラグイン(手元のElasticsearchインストール済みの環境であれば、「elasticsearch-plugin install analysis-kuromoji」でインストールできました。 )
  2. kuromojiの「readingformフィルター」
  3. ICU Analysis プラグイン(「elasticsearch-plugin install analysis-icu」でインストールできます。)のHiragana-Katakana
  4. sort
  5. painless script
  6. predicate_token_filter
  7. インデックスのmapping/settingでの、「"fielddata" : true」

なお、私が最初にこの「問題設定」を考えた時ですが、上記のキーワードのいくつかを漠然とと思い浮かべました。

そして、私でも知っているような比較的単純な設定の組み合わせで簡単にできると思っていました。

しかし、いざ念の為に試してみるかと設定しはじめたところ、それぞれの機能の役割分担からいうと思い通りにいかず、難航しました。

最終的には製品の標準の比較的シンプルな設定の組み合わせで実現できたのですが、試行錯誤した分をなんらか形にしたくてこの記事に残しています。

確認したElasitcsearchのバージョンは6.8です。

2. 考え方

アイディア

kuromojiは日本語の辞書を持っており、Elasticsearchのアナライズで言えば、readingformフィルターを使えば、ヨミガナも分かる。 つまり、(あらかじめそのようなデータを作り込まなくても)readingformフィルターしたフィールドを検索クエリのsort条件で使えばいいじゃん(!?)。

なお、辞書に無い単語、辞書にあるものの前後の単語との関係によっては、正しく読みがつかない場合があるが、そこは割り切りとしましょう。

前提知識などの参考情報

上記のアイディアに関連する、公式の各リファレンスのページへのリンクをまとめて貼り付けておきます。

www.elastic.co

www.elastic.co

www.elastic.co

www.elastic.co

www.elastic.co

3. 実例

以下、アナライズの設定、試験用データのインポート、実際の検索の例です。

3-1. setting/mapping

こんな感じです。少し冗長な設定も入っています。

PUT an
{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                }
            },
            "analyzer": {
                "my_ja-default_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "kuromoji_iteration_mark",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_baseform",
                        "kuromoji_part_of_speech",
                        "ja_stop",
                        "uppercase",
                        "kuromoji_number",
                        "kuromoji_stemmer"
                    ]
                },
                "initial": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "uppercase",
                        "hiragana_2_katakana",
                        "get_initial_filter"
                    ]
                },
                "initial_shippai1": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "char_filter": [
                        "icu_normalizer",
                        "html_strip"
                    ],
                    "filter": [
                        "kuromoji_readingform",
                        "uppercase",
                        "hiragana_2_katakana"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                },
                "e_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 10
                },
                "ngram_filter": {
                    "type": "ngram",
                    "min_gram": 2,
                    "max_gram": 3,
                    "token_chars": [
                        "letter",
                        "digit",
                        "symbol"
                    ]
                },
                "get_initial_filter": {
                    "type": "predicate_token_filter",
                    "script": {
                        "source": "token.getStartOffset() === 0"
                    }
                }
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "my_ja-default_anlz",
                            "fielddata": true,
                            "store": true,
                            "fields": {
                                "ini": {
                                    "type": "text",
                                    "analyzer": "initial",
                                    "fielddata": true
                                },
                                "ini_NG": {
                                    "type": "text",
                                    "analyzer": "initial_shippai1",
                                    "fielddata": true
                                },
                                
                                "raw": {
                                    "type": "keyword"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

注!!!
initial_shippai1
ini_NG
は付録で述べている、うまく行かなかった場合のコンフィグなので、このプロパティの設定は不要です。

解説

fielddataはデフォルトではfalseですが、明示的にtrueにしています。fielddataは様々な意味あいがありますが、今回の問題においては、これをtrueとすることにより、sort条件にこのフィールドを指定できるようになります。

get_initial_filterを「predicate_token_filter」を利用して定義しています。 これは、sourceで指定した条件式に該当するようなトークンのみ、「フィルター」後に残すというフィルターです。 少し似た使い勝手のものに、「condition」というフィルターもあり、これは、sourceで指定した条件式に該当するトークンについて、指定のfilterを適用するというものです。

source部分の、「token.getStartOffset() === 0」は 形態素解析分かち書きなどのトーカナイザーの処理の結果の「1つめのトークン」に該当する場合、trueと判定(それ以外はfalse)するという意味になります。

token.getStartOffset() ↓ www.elastic.co ※1つめのトークンという意味だと、getPosition()メソッドの方がより厳密かもしれません。

この結果、例えば、「購買部」という単語であれば、次のようにアナライズされます。

f:id:azotar:20200119231153p:plain

POST an/_analyze
{
  "analyzer": "my_ja-default_anlz",
  "text": "購買部"
}

↓ アナライズ

{
  "tokens" : [
    {
      "token" : "購買",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "部",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}


一方

POST an/_analyze
{
  "analyzer": "initial",
  "text": "購買部"
}

↓  アナライズ

{
  "tokens" : [
    {
      "token" : "コウバイ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

分かち書きやカナへの読み替えはともかく、1つめのトークンのみ残すというのは、ここではなぜわざわざという感想もありますが、「1つめのトークン」部分のみ残すところがポイントです。ここは、付録でもう少し補足します。

さて、このsetting/mappingの設定では、「initial」アナライザーは、get_initial_filterを使い、インプットのテキストの最初の単語のカナ読みを転置インデックスに格納することになります。

その上で、元のフィールド名に拡張子「.ini」をつけた名前でアクセスできるという設定をしています。

こにより、検索クエリでもともとのフィールド名が「title」で「購買部」あれば、「title.ini」で「コウバイ」というワードが取得できることになります。

3-2 確認

確認用データのインポート

検索対象のデータです。 少々雑ですが、kibanaに貼り付け用の形式で示します。

POST an/_doc/
{  "A":"耳鼻科"}

POST an/_doc/
{  "A":"購買部"}

POST an/_doc/
{  "A":"三澤屋"}

POST an/_doc/
{ "A":"部屋"}

POST an/_doc/
{  "A":"眼科"}

POST an/_doc/
{  "A":"内科"}

POST an/_doc/
{  "A":"心療内科"}

POST an/_doc/
{  "A":"技術部"}

POST an/_doc/
{  "A":"総務部"}

POST an/_doc/
{  "A":"営業部"}

POST an/_doc/
{  "A":"経営企画部"}

POST an/_doc/
{  "A":"研究開発部"}

POST an/_doc/
{  "A":"居酒屋"}

POST an/_doc/
{  "A":"酒屋"}

POST an/_doc/
{  "A":"居酒や"}

検索クエリ

本題の、読み順に並べるクエリです。

この記事では、読み順に並べることが目的ですので、検索条件はmatch_allとしています。

アナライズの結果として生成した、「A.ini」をソート条件に指定しています。

POST an/_search
{
  "query": {"match_all": {}}, "size":100,
  "sort": [
    {
      "A.ini": {
        "order": "asc"
      }
    }
  ]
}

結果

前項の検索クエリの結果です。

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 15,
    "max_score" : null,
    "hits" : [
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Zbchvm8BDQCF7Saowypw",
        "_score" : null,
        "_source" : {
          "A" : "居酒屋"
        },
        "sort" : [
          "イザカヤ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Yrchvm8BDQCF7SaowypQ",
        "_score" : null,
        "_source" : {
          "A" : "営業部"
        },
        "sort" : [
          "エイギョウ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Xbchvm8BDQCF7Saowir8",
        "_score" : null,
        "_source" : {
          "A" : "眼科"
        },
        "sort" : [
          "ガンカ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Z7chvm8BDQCF7SaowyqF",
        "_score" : null,
        "_source" : {
          "A" : "居酒や"
        },
        "sort" : [
          "キョ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "YLchvm8BDQCF7Saowyo1",
        "_score" : null,
        "_source" : {
          "A" : "技術部"
        },
        "sort" : [
          "ギジュツ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Y7chvm8BDQCF7Saowypb",
        "_score" : null,
        "_source" : {
          "A" : "経営企画部"
        },
        "sort" : [
          "ケイエイ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "ZLchvm8BDQCF7Saowypm",
        "_score" : null,
        "_source" : {
          "A" : "研究開発部"
        },
        "sort" : [
          "ケンキュウ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Wrchvm8BDQCF7SaowirH",
        "_score" : null,
        "_source" : {
          "A" : "購買部"
        },
        "sort" : [
          "コウバイ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Zrchvm8BDQCF7Saowyp6",
        "_score" : null,
        "_source" : {
          "A" : "酒屋"
        },
        "sort" : [
          "サカヤ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "X7chvm8BDQCF7Saowyoi",
        "_score" : null,
        "_source" : {
          "A" : "心療内科"
        },
        "sort" : [
          "シンリョウ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Wbchvm8BDQCF7Saowiqx",
        "_score" : null,
        "_source" : {
          "A" : "耳鼻科"
        },
        "sort" : [
          "ジビ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Ybchvm8BDQCF7SaowypF",
        "_score" : null,
        "_source" : {
          "A" : "総務部"
        },
        "sort" : [
          "ソウム"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "Xrchvm8BDQCF7SaowyoV",
        "_score" : null,
        "_source" : {
          "A" : "内科"
        },
        "sort" : [
          "ナイカ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "XLchvm8BDQCF7Saowirq",
        "_score" : null,
        "_source" : {
          "A" : "部屋"
        },
        "sort" : [
          "ヘヤ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "W7chvm8BDQCF7Saowirb",
        "_score" : null,
        "_source" : {
          "A" : "三澤屋"
        },
        "sort" : [
          "ミサワ"
        ]
      }
    ]
  }
}


「居酒や」が「キョ」になっていることはご愛嬌*2ですが、「アイウエオ」順にうまく並んでいるようです。

4.まとめ

漢字タイトルデータをElasticsearchの設定だけで並べてみる...という頭の体操の例を述べてみました。

これが本番に適用できるかという点では他にも考えるべきことがあるでしょうが、逆に、このような実現レベルでも良いので、手間をかけずにある程度並べられるとうれしいという場合には、ありかなと思いました。

◆付録

ボツ案集

実は、最初のころは、次のような方法でより簡単に実現できるのではと考えて試行錯誤したのでしたが、以下の方法では(私の考えの及ぶ範囲では)無理でした。

(1) sort句で次のようなpainless scriptを利用。→ 1単語目の読みを取得できるのでは?

ダメでした。 (painless scriptによる「_script」のソートは、「function_score*3」などの加点型のスコアリングと同じシンタックスで表記できて、これ自体は動作してそうということは確認できるのですが、docで取得できる値がそうそう都合の良いものではなさそうです。

例えば、


POST an/_search
{
  "query": {"match_all": {}}, "size":100,
  "sort": [
    {
      "A.ini_NG": {
        "order": "asc"
      }
    },
    {
      "_script":{
        "type":"string",
         "script":{
           "lang":"painless",
           "source":"doc['A.ini'][0]"
         }
      }
    }
  ]
}

ですが、

次のようになり、思うように並びません。というのも、この例でいうと「sort」の中で戻ってくる値が、その単語の最初のトークンの読みではないためです。

例えば、耳鼻科は、「ジビ」「カ」で、doc['A']でもdoc['A'][0]でも良いので、「ジビ」が戻ってくることを期待するものの、「カ」が戻ってくるという結果になりました。

      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "SrcVvm8BDQCF7SaoJCq6",
        "_score" : null,
        "_source" : {
          "A" : "耳鼻科"
        },
        "sort" : [
          "カ",
          "カ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "VbcVvm8BDQCF7SaoJSpe",
        "_score" : null,
        "_source" : {
          "A" : "研究開発部"
        },
        "sort" : [
          "カイハツ",
          "カイハツ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "TrcVvm8BDQCF7SaoJCr8",
        "_score" : null,
        "_source" : {
          "A" : "眼科"
        },
        "sort" : [
          "ガンカ",
          "ガンカ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "SLcSvm8BDQCF7Sao8yoR",
        "_score" : null,
        "_source" : {
          "A" : "眼科"
        },
        "sort" : [
          "ガンカ",
          "ガンカ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "VLcVvm8BDQCF7SaoJSpT",
        "_score" : null,
        "_source" : {
          "A" : "経営企画部"
        },
        "sort" : [
          "キカク",
          "キカク"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "UbcVvm8BDQCF7SaoJSoz",
        "_score" : null,
        "_source" : {
          "A" : "技術部"
        },
        "sort" : [
          "ギジュツ",
          "ギジュツ"
        ]
      },
      {
        "_index" : "an",
        "_type" : "_doc",
        "_id" : "RbcSvm8BDQCF7Sao8irm",
        "_score" : null,
        "_source" : {
          "A" : "購買部"
        },
        "sort" : [
          "コウバイ",
          "コウバイ"
        ]
      }

つまり、転置インデックスのサガか、 doc[分かち書きされたフィールド][0] は、必ずしも1トークン目を戻すわけではなさそう。

(2) トークン化して読みをあてたあと、それを結合するしかけはないのか? → 転置インデックスを作るためにアナライズしているので、目的からするとそのような仕組みはなさそう/あっても目的外使用になりそうなので、深い追いはしないでおこう。

(3) そもそも、トークンの振る舞いがややこしいので、kuromojiをトーカナイザーに使わず、readingformフィルターだけ適用する。→ 当たり前かもだが、kuromoji_readingformはkuromojiのトーカナイザー前提なのでこの案はボツ。

*1:ただしkuromojiのような日本語形態素解析プラグインは使用。

*2:といっても形態素解析としては正当

*3:Elasticsearch 7.x 系だと、クエリDSL直下に指定できるscript_score クエリーなどもおそらくそうだと思われますが、未確認。

www.elastic.co

www.elastic.co

Jinja(Jinja2)をさわってみる

Jinja(Jinja2)

JinjaはPythonで動作するテンプレートエンジンです。

PythonのWAFのFlaskのデフォルトのテンプレートエンジンでもあるようです。

WAFとの結びつきが強いイメージなのでWebアプリでしか動作しないのかなと思っていたら、コマンドラインPythonでも動作してよしなに文字列を吐き出してくれるようなので、私のような小手先作業ヤローにはもってこいということで勉強してみました。*1

事例

公式サイトへのリンク

jinja.palletsprojects.com

勉強の内容

手元の環境では、

pip install jinja2

で特に苦労もなくインストールできました。

また、実際の使いっぷり(シンタックス習熟)ですが、公式やjinja2でググってまとめていただいているサイトを拝見すれば、また、この類のテンプレートエンジンを利用して勘所が分かっていれば、見よう見まねで慣れることができそうです。

...ということで、実際は解説をするまでもなく、ひとまずこれで、おおよそ基本的なところが分かる・感じられるという程度の事例のファイルを以下にペタペタ貼っておきます。

事例資材の一覧

◆jinja2tmpl/q.tmpl

{# 公式R  https://jinja.palletsprojects.com/en/2.10.x/ #}

{# コメント #}
{% include "header.tmpl" %}
{# ↑インクルード。 なお、ここでは省略するがblock/extendを使うと、親のテンプレートに、子側から子のコンテンツを差し込みすることができる。つまるところ「継承」ですね。 #}

{% set myvar = "テンプレート内の変数" %}
{% set condvar = 3 %}
{{myvar}}
<!-- ↓ if など制御構造 -->
{% if category %}
     {% for i in items %}
        {{category}}--{{i.no}}
     {% endfor %}
{% elif condvar >2 %}     
     エルイフの場合、ここがレンダリング
{% else %}
     エルスの場合、ここがレンダリング
{% endif %}
<!--  if ブロックここまで -->

{# マクロの定義 #}
{% macro myfunc(arg) %}
  {{arg}}
{% endmacro %}

{# マクロの実行 #}
{{myfunc(myvar)}}

{# ここでは省略するが、マクロでcallブロック(caller)を使うと実行側(呼び出し側)から、挙動を制御できる。高階関数みたいな感じ。#}

{#
その他キーワード
・trim_blocks
・エスケープ
・サニタイズ(escapeフィルタ)
・その他の「フィルタ」の活用
・マクロを部品化
・ネストされたインプットデータを再帰的に処理させる場合、「loop」を使うと良い
#}

◆jinja2tmpl/header.tmpl

<div>
ヘッダー
{{category}}
</div>

◆ jinja2sample.py

from jinja2 import Template,Environment,FileSystemLoader

data = {'category':'カテゴリ','items':[{'no':1},{'no':2},{'no':3}]}
env = Environment(loader=FileSystemLoader('jinja2tmpl/'))
print(
    env.get_template('q.tmpl').render(data)
)    

# もっと簡単な例
print(
    Template('''
    {{aaa}}
    ''' ).render({'aaa': '中身'})
)

実行

python jinja2sample.py

などで実行

標準出力への出力イメージ


<div>
ヘッダー
カテゴリ
</div>




テンプレート内の変数
<!-- ↓ if など制御構造 -->

     
        カテゴリ--1
     
        カテゴリ--2
     
        カテゴリ--3
     

<!--  if ブロックここまで -->






  テンプレート内の変数




    中身

以上でした。

*1:ちなみに、私自身は、多くの言語で動作するという点で、mustacheがお好みなのですが、Jinjaはもう一声便利な仕組みをもちつつ、その割にシンプルでしたので、満足しています。それはそうと、Jinjaに限らず、Thymeleafのようにブラウザでローカルファイルとして開いてそれとなくデザインが確認できるテンプレートエンジンは他にはないんだろうか。

検索エンジンでのルックアップ検索型方式に関する講釈とdis_max、constant_score(Elasticsearchを題材に)

はじめに

検索エンジンのスコアリング・チューニングは「関連度」がキモ...だが...

このブログの前の記事で、「BM25」のような情報検索における関連度について、「使わせていただく立場」として載自分なりの講釈を述べてみました。

itdepends.hateblo.jp

関連度やその延長線上にある「評価・ランキング」といったものは、いわばこの分野の華でありいろいろ研究・研鑽が進んでいるんだろうなと思われます。

一方、現場レベルでは、サービス/業務ドメインによっては、関連度以外の方法でスコアリングする方がうまく機能する場合があると感じています。

いや、関連度が機能しないというのは少し違うかもしれません。

過去のデータ管理・運営などで積み上げられたデータ管理結果やデータ整備の運用の努力にうまく乗っかることで、手間をかけずに*1ルールベースの方法でも効果を得られるケースも多くあるのではと考えています。

↓こういうのが基本かもしれないが、、、

f:id:azotar:20200115020601p:plain

↓実はこういうモデルに帰着できるものも多いのでは?

f:id:azotar:20200115020618p:plain

ルックアップ検索モデルと(「関連度」を用いない)定数スコアリングによる検索方法・対象フィールドの序列化

もう少し具体的に言います。

当たり前といえば当たり前なのですが、ある程度データ整備されているドキュメント群においては、関連度に相当するものがあらかじめ整備されています(整備されていることが期待できます)。

この場合、特定の検索ワードでドキュメントのインデックスを検索するよりも、ドキュメントを直接検索する前に、そもそもユーザーの入力ワードを元に、ユーザーが探しているであろう情報カテゴリ自体を検索(「ルックアップ」と呼ぶことにします)し、その後、確定したカテゴリで確実にドキュメントをフィルタリング検索するというやり方も考えられます。

検索の前に検索?、となると一見まどろっこしいです。しかし、膨大なドキュメント検索を、より小さなドメインの検索に置き換えてやるので、もちろんドキュメントの整備され具合に依存はしますが、ルックアップ用の辞書の作り方次第で、ちょっとした工夫の効果が出やすいと思われます。

このような、ルックアップ方式においては、検索時に関連度は用いないものの、検索エンジン分かち書きやフィールドごとの重み付けの制御などスコアリングによる並び順制御の仕掛けはしっかり活用します。 *2

前置きが長くなりましたが、このようなルックアップ検索においては、Elasticsearchでの例でいうと、dis_maxクエリと、constant_scoreクエリが使い勝手が良いと感じているので、この記事では、これらのの紹介がてら、あらためて講釈をたれてみました。

※Elasticsearchのバージョンは6.4で確認しています。

※講釈は不要だ、クエリのシンタックス例をみたいというかたは前半部分をスルーして後半部分をご参照ください。

ルックアップ用の辞書の基本フォーマット

あくまで私の経験の範囲ですが、ルックアップ用の辞書は次のような構造が使いやすいと感じています。また、同じような手法で対応できそうな場合の定番形式として私は推しているものになります。

ルックアップについては、正式名称1つを1ドキュメントと見立てて、「正式名称」フィールドそのものも含めて、様々な検索条件で網をかけて、ユーザーの検索語から導ける、おすすめの「カテゴリ(の正式名称)」を取得するというやり方になります。 よって、正式名称は、この辞書データのユニークキーであり、ルックアップの戻り値の必須項目です。

f:id:azotar:20200115020833p:plain

f:id:azotar:20200115021006p:plain

正式名称のカテゴリの同義語、連想語(関連語)などを、ドメインエキスパートからのヒアリングや検索ログなどを元に、メンテナンスしていけば良いでしょう。

なお、このようなルックアップ機構自体を検索エンジンを用いることの隠れたメリット(?)ですが、、RDBのSELECTで厳格に辞書をルックアップする方式に比べて、辞書の管理がかなり気軽になります。

辞書といっても、大辞林広辞苑ではありませんので、語彙間の関係を厳密に定義する必要もなく、強く関係しそうな単語を同義語として設定し、そうでもない単語や利用者によっては検索に使われそうな簡易な自然文などを連想語としてスペース区切りで登録しておけば良いのです。

なお、この記事の主旨とは違いますが、同義語といえば、Elasticsearchの標準で、同義語(synonym)機構がありますので、ここまでの話で、オレは正攻法が知りたいという方向けに、標準機能についてのリンクを引用しておきます。

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-synonym-tokenfilter.html

f:id:azotar:20200115021243p:plain

ルックアップ用途に向いている(とあなたが思う)検索方法の棚卸し

次に、analyze方法と組み合わせた上で、Elasticsearchでの検索方法を以下におさらいしてみます。

これがElasticsearchで検索できる検索方法全てではありませんが、私の中では定番の検索方法(≒ルックアップの名人芸を検討する際にまずは考えるもの)は次のとおりです。

f:id:azotar:20200115021334p:plain

なお、脱線しますが、ご自身の中で、各検索方法とその検索方法の場合にどのような検索の編みかけがなされる傾向があるかの定性的な特徴を棚卸ししておくと、ルックアップ用途に限らず、検索チューニングの際に頭が整理されて検討しやすくなりますので、おすすめです。

ルックアップ処理モデルの見極め

続いて、ルックアップ用の辞書の各フィールドとそれぞれのフィールドに対する検索方法を組み合わせて、それらのどの検索方法の優先度(スコア: すなわちboost値設定)を高めにすると、もっともらしい「カテゴリ」が得られるかを考察します。

例えば、以下のようなまとめを行ってみます。

f:id:azotar:20200115021503p:plain

対象ドメインにより異なりますが、基本は「正式名称」にぴったり一致するほど優先し、そうでないものは優先度を落とすことになります。肉と入れられて、ステーキハウスを連想するか、焼肉屋を連想するかは悩ましいですが、ハンバーガーと入れられた場合は、ハンバーガー屋カテゴリをまずは第一優先で連想することになると思います。

ここで、私の経験則・主観ですが、edge ngramでanalyzeしたものを(全文)検索(図中のE)と全文検索(token化以外の各種filter適用)(図中のF)の間に、「適合率」と「再現率」の壁があるように感じています。

Eより上は、うまくやればうまくやるほど、納得度の高いの検索結果が得られます。本記事のルックアップの例で言いうとルックアップで得られるカテゴリの尤もらしさが向上します。一方、その分だけ「再現率」が下がってしまうので、当たらないよりはマシでしょという発想で、F以下の検索方法で補ってやるという考え方です。もちろん、あくまで保険なのでFより下の検索方法は、それより上のものよりかなり評価は低めです。

ルックアップ検索モデルと定数スコアリング/検索方法と検索フィールドの序列化

あとは、この優先順に従い、検索方法とフィールドごとの重み付けでのboost設定を行った検索(ルックアップ)をしてやることになります。

... というような考察の結果、前述の図表のとおり、検索方法と検索対象のフィールドの組み合わせ(◯:この組み合わせの検索を実施、△:検索PoCの状況見合いでルックアップ対象としてみる)と優先度(図中の矢印。boost値の重み付け順イメージ)を見定めることになるのですが、だいたいどの例でもこのパターンにおちつく気がします。

さて、ここでは、「ルックアップ検索」のような小さい問題に検索モデルを置き換えることで、関連度のように曲線的なスコアリング(設計上のおおよその優先度はあるもののオーバーラップもありうることで要件に対応している方式)ではなく、設計上の優先順に確実に並べるような、実質ルールベースに近い「決定型」のスタンスでの、確実な制御・チューニングを行うことになります。

ということで、長くなりましたが、やっと、次項でElasticsearchのdis_maxクエリとconstant_scoreクエリおよびこれらの組み合わせのクエリのシンタックス紹介です。

dis_maxクエリとconstant_scoreクエリ

dis_maxクエリ

www.elastic.co

本来はいろいろあると思いますが、俗に言うと、複数の検索条件のOR検索です。 dis_max.queriesプロパティの配列に、ElasticsearchのリーフDSLクエリを列挙できます。 列挙されたリーフDSLクエリのそれぞれで検索されて、どれか一つでも該当すれば、該当したドキュメントが検索結果として戻ってきます。

dis_max部分をbool、queries部分をshouldに置き換えた検索クエリでも同じ検索結果ドキュメントの集合が得られますが、bool-should版が、それぞれの検索条件の加算のスコアリングになることに比べて、dis_maxでは、検索条件のうちもっとも単体スコアが高いものが、そのまま総合スコアとして採用される違いがあります。

f:id:azotar:20200115022731p:plain

加算型のモデルは関連度を意識した検索サービスの場合は、関連度が強ければ強いほど高評価という感覚に合うのでその点では良いですが、今回のように関連度は用いず検索対象のフィールドや検索方法から序列を決めてやろうとしている例の場合は、過剰評価になるきらいがあります。 その点、dis_maxは単体スコアの最高点=総合評価スコアなので、どの検索条件でどのフィールドに合致したかによって、スコアの序列を制御しやすいです。

constant_scoreクエリ

www.elastic.co

constant_scoreクエリは、boost指定された値をそのままスコアに使います。BM25などの関連度はスコアに関与しません。

dis_maxとconstant_scoreの組み合わせ

実際のシンタックスの例として、dis_maxとconstant_scoreの合わせ技の検索クエリ例を示します。

今回のルックアップの件にそった意味の例にはなっていないです。ご注意ください。

GET /20191231/_search
{
  "query": {
    "dis_max": {
      "queries": [
        {
          "constant_score": {
            "filter": {
              "terms": {
                "A.raw": [
                  "東京都","川崎市"
                ]
              }
            },
            "boost": 1000
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A": {
                  "query":"荒川区",
                  "operator":"and"
                }
              }
            },
            "boost": 10
          }
        }
      ]
    }
  }
}

この例だと、次のようになります。

  1. Aフィールドに、東京都,荒川区 という値のドキュメント →スコアが1000で戻る
  2. Aフィールドに、東京都,川崎市,荒川区 という値のドキュメント →スコアが1000で戻る
  3. Aフィールドに、荒川区 という値のドキュメント →スコアが10で戻る
  4. Aフィールドに、品川区 という値のドキュメント →検索結果に含まれない

FYI

長くなったのでやや尻切れトンボ気味ですが、本論はここまでで終わりです。

以下、関連情報+付録です。

Elasticsearchのpercolator

今回は述べていませんが、Elasticsearchには、今回述べたような「ルックアップ」あるいは類似ドキュメントを元にした「分類」相当を、検索エンジンの機構ならではの仕組みで実現するpercolatorという仕掛けがあります。

これは非常に面白いしかけです。

このブログの過去記事で紹介していますので、過去記事を参照ください。 (過去記事では、公式リファレンスへのリンクも行っています。)

itdepends.hateblo.jp

私はルックアップ用途以外はmulti_match推しです(宣伝)

今回はルックアップを例にしたため、dis_maxとconstant_scoreをピックアップしました。 しかし、私は、基本は「multi_match」と対象とする検索サービスの基本の検索パターンから逆算した、「bool」クエリでのmustとshouldの組み合わせの型を定めてその中で動くモデルを好んでいます。ルックアップモデルではうまく行かないような検索要件の場合はもちろんBM25などの関連度を活かした検索モデルを検討することになるのですが、このような例の考察については、こちらの過去記事で講釈の述べています。もしよろしければご参照ください。

itdepends.hateblo.jp

itdepends.hateblo.jp

なお、ここまで言っておいてなんですが、dismax自体は、luceneの世界でDisjunctionMaxQueryとして歴史があるものです。この記事では「関連度」を用いない「ルックアップ」用途にクエリの見栄えも含めてしっくりくる...という私の所見でdismax推しですが、「関連度」を意識した検索に不向きという意味ではありません。ご注意ください。

付録

settingおよびmapping設定

今回のルックアップでは、検索対象のフィールドの数は多くないのですが、同じフィールドを様々な検索方法で検索します。 前述の検索方法のパターンに対応したanalyzeパターンの設定の例を以下に示します。

PUT an
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        },
        "my_ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        },
        "my_4gram_tokenizer": {
          "type": "ngram",
          "min_gram": 4,
          "max_gram": 4,
          "token_chars": [
            "letter",
            "digit",
            "symbol"
          ]
        },
        "my_e_ngram_tokenizer": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 10
        }
      },
      "analyzer": {
        "my_ja_default_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "ja_stop",
            "lowercase",
            "kuromoji_number",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_analyzer1": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "lowercase",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_analyzer2": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_baseform",
            "lowercase",
            "kuromoji_stemmer"
          ]
        },
        "my_ja_readingform_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark",
            "html_strip"
          ],
          "filter": [
            "kuromoji_readingform",
            "lowercase",
            "hiragana_2_katakana",
            "kuromoji_stemmer"
          ]
        },
        "my_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "my_ngram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        },
        "my_4gram_analyzer": {
          "type": "custom",
          "tokenizer": "my_4gram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        },
        "my_e_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "my_e_ngram_tokenizer",
          "char_filter": [
            "icu_normalizer",
            "html_strip"
          ],
          "filter": []
        }
      },
      "filter": {
        "hiragana_2_katakana": {
          "type": "icu_transform",
          "id": "Hiragana-Katakana"
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "location": {
          "type": "geo_point"
        }
      },
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true,
              "store": true,
              "fields": {
                "ja1": {
                  "type": "text",
                  "analyzer": "my_ja_analyzer1"
                },
                "ja2": {
                  "type": "text",
                  "analyzer": "my_ja_analyzer2"
                },
                "rf": {
                  "type": "text",
                  "analyzer": "my_ja_readingform_analyzer"
                },
                "ng": {
                  "type": "text",
                  "analyzer": "my_ngram_analyzer"
                },
                "4g": {
                  "type": "text",
                  "analyzer": "my_4gram_analyzer"
                },
                "e_ng": {
                  "type": "text",
                  "analyzer": "my_e_ngram_analyzer"
                },
                "raw": {
                  "type": "keyword"
                }
              }
            }
          }
        }
      ]
    }
  }
}


クエリビルダー

本記事でテーマにした、「ルックアップ」ですが、dis_maxクエリに職人芸で様々な判定・編みかけ用のクエリをほおりこみます。 真面目にクエリを作っていたら手間ですので、例えば、次のようなクエリビルダー(Pythonです)みたいなものを考えて、検索方法と対象フィールドとboost値をコンフィグ設定(プログラム中のQUERY_CONFリスト)から生成してやるようにすると良いでしょう。

import json

def terms(f,b,q):
    dsl = {"constant_score": {"filter":
        {"terms": {
            f: q.split()
        }},
        "boost":b
    }}
    return dsl

def match(f, b, q):
    dsl = {"constant_score": {"filter":
        {"match": {
            f: {
                "query": q,
                "operator":"and"
            }
        }}},
        "boost": b
    }
    return dsl

T = terms
M = match

QUERY_CONF = [
    [T, 'A.raw', 100],
    [M, 'A.ja1', 10]
    # こんな感じで追加... 
]

QS = "東京都  川崎市  ".replace(' ',' ')

queries = []

for i in QUERY_CONF:
    queries.append(i[0](i[1],i[2],QS))

es_query = {"query": {"dis_max": {"query":
        queries
    }}}

print(json.dumps(es_query,ensure_ascii=False,indent=2))

*1:正確にはすでにある程度かかっている手間を有効活用することということになります。

*2:活用することで、grepRDB/SQLのLIKEなどによる検索/ルックアップよりももう一段うまい効果が得られることが期待できます。

BM25を分かった気になるかもしれない邪道な解説(?)

1. はじめに

このブログではElasticsearchについて時々思い出したように書きなぐっております。

そしてこの記事では、Elasticsearchのデフォルトのスコアリング方式であるBM25について、数式が苦手でも、なんとなく分かった気になる(かもしれない)解説を試みてみます。

邪道に徹しておりますので、(私自身もそうですが)できれば数式を見たく無い人向けに「デフォルメ」した言い方をしているところもあります。 この点をご容赦いただくとともに、デフォルメを超えて間違っているじゃんというところがあればこっそり教えてください。

正攻法や、数学的・情報科学的に正確な手法としての説明をお求めの方は、末尾の参考リンク先などをあたってください。

2. BM25やTF-IDFのアイディア

BM25やそのある種前身であるTF-IDFについて、使う側の立場としてこれらの手法を理解するには、どのようなドキュメントおよび検索語の場合に評価(スコア)が高くなるか/高評価とするかを掴むのが早道だと思います。

ということで、どんな時に評価が高いの?というところの3大要素を以下にあげてみます。

  1. 検索語の出現数が多いと高評価。
  2. ここで、レアな検索語であれば、出現数評価の重みをさらにUPする。
  3. ここで、検索語の出現割合が高い文書であれば、さらに出現数評価の重みをUPする。

f:id:azotar:20200103163310p:plain

ひとつめは、比較的感覚にそったものになっていると思います。 ふたつめ、みっつめは、ひとつめの弱点をカバーする、あるいはスコアにメリハリをもたらすものです。

以下に分かった気になるかもしれない、ひとことウンチク入りのイメージ図をはりつけてみます。 (上記の3点を、検索語の出現数、レアな検索語、密度が高い... のようにキャッチコピー的に言い換えています。)

1. 検索語の出現数

検索語の出現数が多いと高評価というのは(何か弱点はあるかもしれないですが)特に反対する理由はないと思います。 これはBag of Wordsという、より古典的な考え方そのものらしいです。

f:id:azotar:20200103163349p:plain

2. レアな検索語

こちらは「レア度」の評価がポイントになりますが、全ドキュメント数のうち、そのワード(検索語)を含むドキュメント数の比率を使います。 ドキュメント検索の例ではありませんが、山田さんは名簿の中に5%もいるが、綾小路さんは一人だけ...という具合です。 なお、分かった気になるには「レア度」のように希少価値に目を向けた方が良いと思いこのように説明していますが、英語でいうところの「a」や「the」など数を数えてもそれほど意味をなさない(ただしこれらは検索に使われることもまれですが)ようなワードを除外する効果もあります。

f:id:azotar:20200103163420p:plain

3. 密度が高い

こちらは、長い文章になんどかそのワードが出てくるという例よりも、(相対的に)短めの文章の中で繰り返しそのワードが出てくるような例の方がそのワードについて詳しく述べているというアイディアになります。 実際には、全ドキュメントの平均ワード数に対してスコア評価対象となるドキュメントのワード数の比率から、そのドキュメントではあるワードに対して集中的・専門的に述べているのかを見出します。 (TF-IDFに対するBM25の最大の改善ポイントはこの観点によるものです。)

f:id:azotar:20200103163535p:plain

3. スコア値の傾向を見てみる

BM25の定義

数式の説明はボロが出るのでここでは行いませんが、パワポで数式エディタを使ってみたかったのでひとまず下記に引用します。

f:id:azotar:20200103163856p:plain

利用シーンを仮定してできるだけ具体的な数値におきかえする

数式での厳密な定義はともかく、BM25のパラメータは以下の6つ(6個目は2つ分なのでそうとらえると7つ)です。

f:id:azotar:20200103163937p:plain

ここで、まともに考えると、ある検索語とそれにマッチしたドキュメントのそれぞれのBM25によるスコアは、検索語とドキュメントのその都度の関係です。

よって、本来であれば、その都度の計算にはなります。

一方、肌感覚をつかむという意味では、実際は定数と思っても良さそうな変数については、いっそ定数だと思って値を当てはめて計算することにしましょう、グラフを書いてみましょうというところが良いでしょう。

BM25やTF-IDFの評価はパラメータも多く動的なものになりますが、ご自身が検索サービスとして対象としているドキュメント群や想定検索シナリオを例にとると、大半のパラメータは定数とみなして値を当てはめてしまっても良さそうです。

例えば、次のように考えてみます。

f:id:azotar:20200105103026p:plain

評価(score)の計算式について、変数が2つだけの式に変形できました。変数を減らしたことで、この時点でグラフの形が想像しやすくなっています。

具体的には、左のlogの項はdが大きくなるほど0に近くづきます*1ことと、右の項も2.2に近くことが予想できます。

BM25のスコア値のグラフ

実際にグラフをえがいてみます。

検索語Wで、あるドキュメントDのスコアを計算しました、というグラフです。

  • 3つグラフがあります。タイトルにあるように、全ドキュメント数を1,000,000、100,000、10,000と変えてみたグラフで、それ以外は同じ条件です。
  • 縦軸は、あるドキュメントDのBM25のスコアです。
  • 各系列はtf=●●、dl/avgdl=▲▲とあります。tfはスコアを計算したいドキュメントDに対して、この検索語Wが何回出現したかです。 dl/avgdlは、このドキュメントDのワード数と全ドキュメントの平均ワード数との比です。平均の2倍のワード数なら2となります。
  • 横軸はその検索語Wが、全ドキュメント中、何件のドキュメントに出現するようなワードであったかを示します。つまり横軸の右にいけばいくほど、Wはありふれた単語に該当するということになります。

f:id:azotar:20200103165227p:plain

f:id:azotar:20200103165220p:plain

f:id:azotar:20200103165213p:plain

縦軸と横軸のスケールは揃えています!!!

グラフの形を見ると(見なくても数式に強い人だったら想像がつく範囲ですが、対数(log)のグラフなので)、 全ドキュメントの多寡によらず、スコアの傾向は似た形状を辿りそうです。

気づき

前述の3つのポイントはそれはそれで見て取れるのですが、実際に値を当てはめてみた上で、私見ではここでの見どころは次のように捉えています。

  1. グラフがひらがなの「し」の字のようになっている*2。 → (あくまでこの例の範囲から他の場合も想像したにすぎませんが、)相当レアな検索語でなければ、思ったほどスコアの差が付かない。3つのグラフで、全ドキュメント数の多い少ないはありますが、横軸の20あたりにスコアの変曲点のようなものがあり、20件を超える出現数の検索語だとそれ以上の出現数と大差ないスコアが付く傾向が見て取れます。
  2. 各グラフは、TF(グラフ図中はtfと表記)の値が同じものごとに同じ色で点線か実線をかえて描画しています。TFの値が大きいもの(グラフ中だと赤いラインのグラフ)はTFの値が小さいもの(青のラインのグラフ)より、「dl/avgdlの値の大きさ(そのドキュメントがワードが多いテキストか少ないテキストか)」によるスコアの差が出にくくなっていることが見て取れます。
  3. 検索語の出現数が極めて少ない(グラフでいうと横軸が0に近く)としても、この定義の範囲では、最大でも40程度の値の範囲である。

まとめと所感

邪道と大きくでたものの、実のところ値を固定してBM25のグラフを描いただけ?となってしまいましたが、グラフを描いてみることでなんとなくわかった気になれていることを目指してみました。

いずれも、もともと定義がそういうものであり、加えてこの記事でそういう係数にしているというところもより後押ししている面もありますが、コンセプトどおりに、評価がなされていることが実感できました(?)。

また、そのようになるように定義・調整したから当たり前といえば当たり前ですが、対数(log)を数式に入れることで、なだらかなグラフになるとともに、過剰に評価が高くなることもないことが実感できました(?)。

それと当時に、ありふれたワードの検索の場合は、コンマいくつの値レベルで評価の差は出るにはでるものの、大きな差にはならないという当たり前のことも分かりました。

いうまでもなくあくまで個人の意見ですが、実際の検索利用シーンに置き換えると、また誤解をおそれずにいうと、BM25などのRelevance Scoringは、あくまで味付けなんだなと思いました。

いまさらですが、少ない検索ワードや曖昧なコンテキストにおいて、利用者の探している情報を検索させるというところの難しさを実感します。

レストラン検索サイトでただただ「ラーメン」で検索させてもどんぐりの背比べになってしまうので、2つめの検索条件としてどのようなものをユーザーに入れてもらえるようにするか選択させるか、およびそもそも各レストランのウリや特徴を捉えたドキュメンテーションやライティングを行うかというところなんだろうなと思う次第です。

そのためには、(利用者(ペルソナ)を決めたもの以外は締め出すなど限定する必要はないものの、)ターゲットとする利用者イメージや利用シーンをいかに高解像度で描けるかが改めて重要だと感じます。 幸い、先人やトップランナーがテクニカルな選択肢を広げてくれているので、それにのっかることでもっともっと先が見れるようになってきている期待がもてますので、自分のような凡人もエンジニアリングをより楽しめいければと令和2年の初頭に思いました...謎の締めでこの記事を終わります。

付録

グラフのプロットに用いたプログラム例

import math
import matplotlib.pyplot as plt
import sys
import matplotlib as mpl
mpl.rcParams['font.family'] = 'Hiragino Maru Gothic Pro'


#DOC_COUNT = 10000
DOC_COUNT = int(sys.argv[1])
#v_docFreq = [i + 1 for i in range(DOC_COUNT)]
v_docFreq = [i + 1 for i in range(1000)]
x = v_docFreq


k1 = 1.25
b = 0.75

def score(tf, x, dl_avgdl):
  # https://www.elastic.co/jp/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables
  return [math.log(DOC_COUNT/(0.5 + n)) * tf * (k1 +1) / (tf + k1*(1-b + b*(dl_avgdl)))  for n in x]

plt.title('BM25スコア値分布イメージ確認: ドキュメント件数' + '{:,}'.format(DOC_COUNT) + '件,' + 'k1=' + str(k1) + ',b=' + str(b))
plt.xlabel('docFreq:検索語を含むドキュメント数')
plt.ylabel('スコア')
tf = [1, 2, 10, 100]
color = ['blue','green','orange','red']
dl_avgdl = [0.1, 1, 2,10]
linestyle = ['dotted','dashdot','dashed','solid']
pp = []

for i, tf_item in enumerate(tf):
  for j,dl_avgdl_item in enumerate(dl_avgdl):
    linewidth = i + 1
    alpha = (i + 1) * 0.1
    s = score(tf_item, x, dl_avgdl_item)
    p, = plt.plot(x ,s, linestyle=linestyle[j], color=color[i], label='tf=' + str(tf_item) + ', dl/avgdl=' + str(dl_avgdl_item).rjust(3,' ')) 
    pp.append(p)
  

plt.ylim(0,40)
plt.legend(handles=pp,bbox_to_anchor=(0.2, 0.95), loc='upper left', borderaxespad=0,ncol=len(tf))
plt.show()


kibanaでElasticsearchにexplainしてBM25計算しているところを見てみる

f:id:azotar:20200105112146p:plain

※ キャプチャすると字が潰れてしまっていますが、よくみると、BM25の数式の構成要素のIDFなどの計算がされていることが見て取れます。 ※ df_query_then_fetchオプションはそれはそれで意味があるのですが、これはまたあらためて。

参考リンク

https://en.wikipedia.org/wiki/Okapi_BM25

https://ja.wikipedia.org/wiki/Okapi_BM25

https://www.elastic.co/jp/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables

https://www.elastic.co/guide/en/elasticsearch/reference/7.5/similarity.html

*1:実用上はlog(-1)としたくないため、「-1」の項は無視する定義を用いるようですがここでは詳しくふれません

*2:この形状については数学的な叙述の仕方、せめて定性的な言い方があったような気がするが...そのうち確認

pandasクックブックの感想を借りた自分メモ

pandasクックブックの部分的な感想

この記事は、こちら↓の書籍の読書感想文です。

pandasクックブック ―Pythonによるデータ処理のレシピ―

pandasの理解が深まるとともに、他では見られない、暗黙のルール(ではないんだろうが私は良くわかってなかったpandasの世界観)が まとまっていて、非常にためになりました。

腹落ちする書籍でした。

なお、書籍は体系的かつ実践的な構成になっていると思いますが、以下は読書時に私が知らなかったことや面白いと思ったことをピックアップしたので、散発的です。 (宣伝になってなかったらごめんなさい... > オーサーの方、訳者の方 )

また、時々私的な見解が混ざっているところがあります。その点に関しては読書感想文ということでご容赦ください。


なお、書籍のもう一つのよいところは、実データなどがひととおりダウンロードできることです。

例えばこちら↓

github.com

この記事の見出し(※構造化されていません)


Pandas基礎など

DataFrameの3要素 インデックス、カラム、データ index, columns, values

>type(df.index)
>     class 'pandas.core.indexes.range.RangeIndex'
> type(df.columns)
>     class 'pandas.core.indexes.base.Index'>
> type(df.values)
>  class 'numpy.ndarray'>
> 

RangeIndexはサブクラスであることを実感

issubclass(pd.RangeIndex, pd.Index)

RangeIndexは必要になるまではメモリにロードされない

p.6 、p.31 に記載の「型の話」

これらのページには普段雰囲気で使っている(※よい子は真似しないようにちゃんと型を理解しましょう。)pandasのDataFrameやSeriesの1セル*1のデータ型に関してまとめてある。 直接の引用は憚られるので、各ページに何の表が書いてあるかだけ示す。 なんとなく、astypeやselect_dtypesが分かったきになる。

p.6 各種データ型とNumPy/pandasオブジェクト、pandas文字列名などの対応表が記載

p.31 Pythonオブジェクトと型を表す文字列

df.dtypes

color                         object
director_name                 object
num_critic_for_reviews       float64
duration                     float64
director_facebook_likes      float64
...
>> df.get_dtype_counts()
float64    13
int64       3
object     12
dtype: int64
>>> 

>> df.select_dtypes()

各カラムのデータ型
df = pd.read_csv('data/movie.csv')

メソッドなどを手軽(?)に確認
dir(pd.Series)

カウント

(以下、srはSeriesのデータ)

sr.size sr.shape, len(sr)

sr.count() 非欠損値のカウント

nunique() ............

sr.describe()

sr.fillna(0) 欠損値を0にセットする
sr.dropna() 欠損値を削除

ブロードキャスト sr + 1

Booleanインデックス

sr > 7True FalseのSeriesを戻す

Pythonの特殊メソッドによって演算子が実現されているのでいろいろできる。

また、mul、addなど具体的なメソッドもある。

◆インデックスとして明示的に特定のカラムを用いる

df.set_index('インデックスにしたいカラム名')

◆カラムをメソッドで選択する...など

sr.all()
sr.any() ※ df.any().any()
sr.isnull() ※欠損値を抜き出す
sr.head()
sr.select_dtypes(include=['int']).head()
sr.filter(like='foo')
sr.filter(regex='\d')
sr.filter(items=['カラム名1','カラム名2'])
 ※インデックス演算子と同じ動きだが、KeyErrorにならない。

Pandasの欠損値

 NumPyのNaNオブジェクト(np.nan) ※ np.nan == np.nan → Falseとなることに注意

◆欠損値を含むDataFrameを比較する場合は、equalsメソッドを使う  eqメソッドとの違いもチェック。

◆メモリ使用量の確認

df.memory_usage(deep=True) →濃度が低いデータの型をカテゴリ型に変換する

◆ df['foo'] = df['foo'].astype('category)

◆よくあるランキング

nsmallest
nlargest
sort_values
drop_duplicates ※keepパラメータ
cummax
cummin
cumsum
※他にも累積値系のメソッドがいくつかあるので活用したいところ

◆ iloc、loc 定番だが、周期的に値を取り出す...といったことがやりやすいと捉えると、df['aaa']の形式といい感じで棲み分けられる気がした。

また、スライス表記(df[1:5]のようなコロンを使ったやつ)はいろいろできて便利(だが、ぱっと見よくわからん)。

◆ iat、atはスカラー値専門。その分、loc系よりも高速。

◆p.85

clip_lower clip_upper clip idmax

hxn.blog.jp


Booleanインデックス法

Booleanインデックス法/Boolean選択 (およびこれらの対象カラムにインデックスを用いるインデックス選択)

df.colname > 8 df['colname'] > 8 →True, FalseのSeriesが得られる

論理演算は、Pythonの「and, or , not」ではなく、「&,|,~」 を使う。

df.colname > 8 | df.colname < 5

この章の後半には、SQLの代用というワークショップのセクションがあって、いろいろテクニックを駆使する様がおもしろい。

query、where、maskメソッド (queryは、Booleanインデックス法を読みやすさ改善するものだととらえてよい。 ちなみに、@varname など@をつけてPython変数を参照することもできる)


インデックスアラインメント

これまた雰囲気で理解していたpandasのインデックスアライメントの特徴がまとめられて参考になる。 この章の末尾の演習などで実際の性質を確かめておくと非常に勉強になりそう。

... ので気になる人はぜひ書籍を読んでください。

ここでは、インデックスアラインメントではなく、途中に紹介のあった、Indexオブジェクトに関する演算などに関して、こんなこともできるよというところのメモ

◆Indexオブジェクトに算術演算子や比較演算が可能 →これらを使えば機械的に名称を変更できる

df.columns + '_A'

df.columns > 'G'

Seriesやndarrayと同じような演算ができる。

和、積、差、対称差などの集合演算も可能

union, symmetric_difference...
difference

addメソッドは加算演算子(+)と同様。
ただし、fill_valueパラメータで非合致インデックスを処理できる(便利!)

Groupby

◆直接groupbyオブジェクトにメソッドチェイニングできる集約関数

min,max,mean,median,sum,count,std,var,size,describe,nunique,idxmin,idxmax

◆p.153

aggメソッドを使って複数カラムを集約すると2階層のインデックスオブジェクト が作られる。集約からカラムが上、集約関数が下。

◆ p.150

グループ分け時のMultiIndex解消

◆イディオム

df.columns = df.get_level_values(0) + '_' + df.get_level_values(1)
の後に、reset_index()

◆集約関数は *args、**kwargsを利用できる。

.agg(myfunc, 100,200) や .agg(myfunc, low=100,high=200)

◆p.160~

print([attr for attr in dir(grouped) if not attr.startswith('_')])

◆グループの数

grouped.ngroups

◆キー取得

grouped.groups.keys()

◆グループを取得

grouped.get_group(【グループラベルのタプル】)

◆ざっとオーバービューする時の定石

from IPython.display import display

from n,g in grouped
print(n)
display(g.head(3))

grouped.head(2).head(6)

grouped.nth([1,-1]).head(8) nthメソッドにより各グループの先頭と末尾の行
を取得

◆groupオブジェクトの/あるいはgroupオブジェクトを操作するにあたりに役立ち そうなメソッド

agg
apply

は定番として、

filter *
transform
pivot
np.where

あたり。

◆transformについて... ↓

ほかにもいろいろできるが、groupbyする前のDataFrameにグループ化後の集約値 を一律付与することができる。 ※groupbyする前のDataFrameと同じ行数となる。 → 私はこの手のgroupby後にapplyして得られた集約値を元のDataFrameにカラム追加するということをしばしばやっていたが、  ひょっとするとtransform一発で書けたのかもとこれを見て思った。

◆ pd.SeriesではなくあえてOrderDictを使うというスタイルもある。

◆グループをSeriesとDataFrameどちらで返すかでカタチが変わる。

◆ NumPy特にstatsモジュールは利用しがいがある。

◆ pandas cut 関数

bins = [-np.inf, 200, 500, 1000, 2000, np.inf]
cuts = pd.cut(foo['BAR'], bins=bins)
fooデータフレームのBARカラムの値を離散化

離散値などによりbin詰めしたグループ化ができる。

◆pandasではgroupby メソッドに任意のオブジェクトを渡せる (現在のDataFrameに全く関係ないものでグループ分けできる!)

diff shift


整然形式にデータを再構成

tidyデータを/tidyデータになるようにあれこれの例が載っている。 pandasの特にindexのマスターになるにはこれに良くなれておくのが良いだろう。

  • 65 変数値カラム名をstackで整然化(tidy化)
  • 66 変数値カラム名をmeltで整然化 例. りんごとオレンジとバナナの地域ごとの生産量クロス集計を整然化

コツ stack()、reset_index()、columsで代入あるいは Seriesのrename_axisメソッド を使ったのちにreset_index()

別解 meltメソッドを使う

ちなみに、水平方向のカラム名を鉛直方向のカラム値に変形することを melting、stacking、unpivoting などと呼ぶ

  • 67 複数の変数グループを同時にスタック 映画の出演者1の名前とSNSのイイねの数、~出演者3までの同項目の表を整然 データにする wide_to_long

  • 68 スタックしたデータを元に戻す unstack、pivot

  • 69 groupby集約の後でunstack クロス集計表の縦横転置

  • 70 groupby集約でpivot_tableの代用

  • 71 変形を容易にするレベル軸の名前変更 rename_axisによりカラムレベル自体に名前をつける cg.rename_axis(['AGG_COLS','AGG_FUNCS'], axis='columns') swaplevelメソッド sort_indexメソッド

  • 72 複数の変数がカラム名になっている場合の整然化

  • 73 複数の変数がカラム値の場合の整然化 MultiIndexメソッドのdroplevel squeeze

  • 74 複数の値が同じセルにある場合の整然化

  • 75 変数がカラム名とカラム値になっている場合の整然化 各地域の気圧、気温、湿度がそれぞれ各行(1地域あたり3行)に記録されている。 年度ごとのこの値が水平方向に格納されている。 水平方向の時系列。

  • 76 複数の観察が同じテーブルにある場合の整然化


pandasオブジェクトの結合 (p.233)

結合

append concat merge

.locで1行追加することもできる。
names.loc[len(names)] = {'Name':'FOO','Age':15}

◆ append ignore_indexパラメータ (appendそのものではないが) Series.to_dict() とリスト内包表記

◆ concat 個人的に意外だった使い方がいくつかあった。

s_list = [データフレーム1, データフレーム2] pd.concat(s_list) → 鉛直に連結する。 pd.concat(s_list, keys=['2016','2017'], names=['Year','Symbol']) ラベル指定とインデックスレベルの名前を変えることができる。

read_htmlでダイレクトにhtml中の表をDataFrameに取込できる match, attrsパラメータの使いこなしがキモ。 ここでも欠損値を取り扱うffill, fillnaが活躍

Pythonの辞書アンパッキング →(辞書)アンパッキングと呼ぶのね

data = (3,5,7)
san,go,nana = data

のようなやつのこと。

パラメータ名と値を含む辞書を、**をつけて関数に渡す、例えば、
func(**ABCval)
などとやるとABCvalの辞書を元に
func(a='x1',b='x2',c='x3')と指定したような扱いになる

◆ p.255 concat、join、mergeの違い 個人的には、 ①concatはインデックスに重複があればエラー 他2つは、デカルト積計算 ②3者のアラインメントの違い

というところがまとめてあったのがうれしい。

この書籍の章で学んだことは、こちら↓の記事に活きてます。

itdepends.hateblo.jp

以上

*1:※セルというのはEXCELの影響で便宜上このように呼びました

Pandas文学論なんちて

はじめに

Pandasで遊んでいて、便利だなーと思う一方で、Pandasでのある種のDSLとしての記法やライブラリをうまく使えばもっとシンプルに、かつ可読性(ここでは、ビジネスルールとデータクレンジングなどのための前処理(の前処理)をうまく分離したもの)をあげた記述ができるんじゃないだろうかともより欲が出てくることがあります。

Pythonは比較的、誰が書いても近い記述になりやすいと言えそうですが、Pandasはそこにある種の混沌を持ち込む感じがします。

ということで、私がPandasで遊ぶ時に良く出くわす、次の図の処理パターンあたりを念頭に、Pandasでの(より)シンプルで、ビジネスルールをダイレクトに表している(と私が思う)記述スタイル・イディオムを棚卸しすると共に、自分なりに、こういうスタイルが良いんではないだろうかというところを書き下してみました。

f:id:azotar:20190822065026p:plain

記法の棚卸し

ということで/といっても、凝集度など客観的な数字を用いる話ではなくて、プログラムの文字通り文字ヅラの並び方などの見栄えの話なのでPandasのシンタックスのご紹介が必要かなと思っています。

よって、このセクションでは説明と実例を兼ねてコメントモリモリでプログラムを貼り付けています。 これ自体が長く読みづらいコードになってしまっていることについてはご容赦くださいませ。

キーワード

  • ブロードキャスト
  • インデックスアラインメント
  • 列名指定の列選択とlocによる行列選択
  • Booleanインデックス
  • applyメソッド

解説兼事例のプログラム

私がPandasの特徴的&便利だと思っている記法を紹介するプログラム例です。

Pandas動作環境が整っている環境であれば、これをまるっと貼り付ければ動作します。

import pandas as pd
import numpy as np
import sys

"""
◆はじめに
Python自体は他のプログラミング言語に比べると誰が書いても同じスタイルになりやすい言語だと思います。
ただ、PandasやNumpyを用いるとそうでもなくなる気がしています。
Pandasは通常のPython以上に、自分が書いたコードでさえ、後から見ると何をやっているか分からないというのが発生しやすいと思います。
(お前のコーディングスキルのせいだ!というところは突っ込まないでください。)

実際にこのような課題を解消できるかはともかく、よくある同じ結果が得られるシンタックスを複数並べてみて、私はこのスタイルが好みかな〜という例を棚卸ししてみたいと思いこの記事を書きました。

つまるところ、Pandasでドメイン知識をできるだけうまく表すことができるコーディングスタイルとはというところについて、感じたところを書いています。

"""

"""
◆まえおき

無尽蔵にシンタックスを掘り下げてもきりがありません。また、次のURLのように有識者の方がパターンをまとめていただいていますので、Pandas全体を俯瞰するのはサイトにお譲りして、次項に述べたケースに絞って述べることにしています。

参考URL:
https://ikatakos.com/pot/programming/python/packages/pandas/update_multi_column

なお、本来は、コードがドメイン知識をうまく表現しているかという話とは別に/場合によってはそれ以前に、性能(パフォーマンス)、可読性、要件によらないレベルの最低限のカプセル化や構造化の各種作法があり、本来はこれらを差し置いた話をするべきではありません。また、ETL周辺の場合は欠損値の処理も本来は考えるべきです。

ただし、ここでは、論法・積み上げとして甘いところもあると思いますが、私が感じたものを中心に書き連ねています。
その点ご了承ください。


◆念頭においたトランスフォームのパターン

・各行の複数のカラムを元に、ビジネスルール等に従い、別の測定値を算出し、新たなカラムに設定する。
・各行の複数のカラムを元に、ビジネスルール等に従い、別の測定値A、B、Cなど複数の測定値を算出し、それぞれ別の新たなカラムに設定する。

このブログでは、Elasticsearchなど検索エンジンがらみについて自説を述べることが多いのですが、これら検索エンジンを有効活用するにあたり、データのインデクサーで頑張る系の処理を行う場合に上記のようなものが良く出てくるような気がするので、上記に注目しています。

逆に言うと、他のETLやデータ分析によってはあまりあてはまらないものもあると思います。

例えば、時系列分析などは時系列に従い縦方向のアプローチが多いかもしれません。

◆注意事項

以下では、「XXXが良いと思います」のような記述が何度か出てきますが、この類の文では、一律、「あるドメイン知識や論理・ルールをPandasでより直接表現できるコーディングスタイルとしては」という条件を省略しているとして、ご覧ください。

"""


"""
============================================================
説明用の初期化など 
------------------------------------------------------------
"""
orig_df = pd.DataFrame(
    dtype='int',
    columns=['X', 'A', 'B', 'C'],
    data=[
        [10000, 1, 11, 111],
        [20000, 2, 22, 222],
        [30000, 3, 33, 333]],
    index=['1_ONE', '2_TWO', '3_THREE'])

df = orig_df.copy()


def __ex(desc=''):
    print(desc)
    print('インプット')
    print(orig_df)
    return orig_df.copy()


def __p(df):
    print('アウトプット')
    print(df)


desc = """
===========================================================
レシピ1:  列名指定による代入など
・ブロードキャスト
・Seriesを代入
・リストを代入 ※ 要素数と行数が一致している必要がある
・assign

いずれも、PandasではないPythonなどではみられない記述& 演算結果になります。

慣れるまではわかりづらいところもありますが、これらはPandasが最低限わかっている人には、
非常に端的にドメイン知識を伝える例となります。また、他の記述の土台となりますので、
これらについては、好き嫌い以前に条件反射で何がおきているか分かるようにしておきましょう。

"""
df = __ex(desc)

df['D'] = 1000
df['E'] = df['X']
df['E2'] = range(len(df))
df = df.assign(F_by_assign=df['B'])
# 感想:assignはそんなに好きではなかったりする...

__p(df)

"""
レシピ1の出力結果

インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333
アウトプット
             X  A   B    C     D      E  E2  F_by_assign
1_ONE    10000  1  11  111  1000  10000   0           11
2_TWO    20000  2  22  222  1000  20000   1           22
3_THREE  30000  3  33  333  1000  30000   2           33

"""


desc = """
===========================================================
レシピ2: Booleanインデックスとインデックスアライメントなど
・locによる行と新列名指定の代入

locを使うと、行と列を同時に選択できます...というのが教科書的な説明になりますが、
私見では、locは行を列の特定の条件で選択して限定したものに対して、特定の列(追加の列でもOK)を
更新する用途に使う ... と捉えることにしています。

逆に言うと、前項の列名指定でカバーできる要件にloc指定は使わない方が良いと思っています。
(例.   loc[:,'A']みたいなのは、意図がない限りは用いない。)

"""
df = __ex(desc)

# 列Cが111より大きいもの....というような条件を意識した処理のイメージ

df.loc[df['C'] > 111, 'D'] = 1000
df.loc[~(df['C'] > 111), 'D'] = 0
# df.fillna({'D': 0})

df.loc[df['C'] > 111, 'E'] = df['X']

__p(df)


"""
レシピ2の出力結果

インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333

アウトプット
             X  A   B    C       D        E
1_ONE    10000  1  11  111     0.0      NaN
2_TWO    20000  2  22  222  1000.0  20000.0
3_THREE  30000  3  33  333  1000.0  30000.0


※上記の補足:

locだと、この条件に該当する行について新たな(既存の)列Dに、列Xの値を設定する(該当しないものはスキップする)という記述を
表している感が読み取りやすいと思いませんか。

"""

desc = """
===========================================================
レシピ3: Booleanインデックスとインデックスアライメントなど・続

・「 df['C'] > 111 」はBooleanインデックスと呼ばれる(らしい)、DataFrameの該当行はTrueそうでない行はFalseの
 dfの行数分のTrue、Falseの配列(PandasなのでSeries)です。
・「 df['C'] > 111 」はBooleanインデックスというSeriesのデータなので、
 変数に入れることができます。
 ・locは、Seriesを受け取れる&受け取ったBooleanインデクスで対象行を絞り込めるので次のコード例のようなことが可能です。

"""

df = __ex(desc)


# 絞り込み条件文に見えているが、実際はTrue/Falseの配列(のようなもの)
#  → 似たものとして、DataFrameのqueryメソッドがあるが、こちらは、クエリに該当するDataFrameそのものを戻す。
#
print('途中経過~~~~~')
cond = df['C'] > 111

print("df['C'] > 111 の正体 ~~")
print(type(cond))
print("df[df['C'] > 111] で得られるDataFrameとインデックスの値 ~~")
print(df[df['C'] > 111])

# 条件文を取り回しして、同じ条件を抽象化できる。
# 特に、特定の条件にあてはまる行とそうでない行の2パターンの変換という場合は、条件を定数風に取り扱いできる。
df.loc[cond, 'D'] = 1000
df.loc[~(cond), 'D'] = 0
df['E'] = df[cond]['X']

print(cond)

__p(df)


"""

レシピ3の出力結果

インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333

途中経過~~~~~
df['C'] > 111 の正体 ~~
<class 'pandas.core.series.Series'>

df[df['C'] > 111] で得られるDataFrameとインデックスの値 ~~
             X  A   B    C
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333
1_ONE      False
2_TWO       True
3_THREE     True
Name: C, dtype: bool

アウトプット
             X  A   B    C       D        E
1_ONE    10000  1  11  111     0.0      NaN
2_TWO    20000  2  22  222  1000.0  20000.0
3_THREE  30000  3  33  333  1000.0  30000.0

上記の補足

condは、条件文風の体裁だが、実は、{1_ONE: False, 2_TWO:True, 3_THREE: True}のような値。

これをlocや、DataFrameの「[]」部分に渡すと、左辺のDataFrameのインデックス値が「2_TWO」「3_THREE」のようにTrueになっているものが選択され、それに合わせた行に演算が作用していることに注目ください。

厳密な説明ではないですが、おおまかに言うと、オペランドの右辺や左辺のDataFrameについてインデックスの値に合わせた暗黙の軸の合わせがなされていることになります。(公式等の用語定義の原典まで至っていないのですが、この類のPandasの動作を「インデックスアライメント」と読んだりするようです)


"""

desc = """
===========================================================
レシピ4: TIPS

ここまで、列の情報による、行の選択の例を示してきましたが、1セットになるシンプルなスイッチ条件的なものは、おおよそそれをそのまま体現する、便利なメソッドがあるものが多いです。以下は、Booleanインデックスを戻り値として返すものです。

□Series.str.containsなど
・ df['お名前'].str.starstwith('山田')

   → お名前列に文字列の先頭に「山田」を含む行のBooleanインデックスが戻る。
   ※ df['お名前']を1要素の文字列型だと思って、Pythonの文字列処理メソッド風に 「 df['お名前'].startswith() 」と記述してもダメ。
    逆に言うと、この誤記述例で本来やりたかったことが、上記で可能。
     

□betweenなど
・between
  →booleanインデックスが戻る
・isin

また、いわゆるビン分けのためのメソッドがあります。(ただし、下記自体はBooleanインデックスではなくそれらと組み合わせて使う類のもの)

□ビンに分割
・cut
(境界値のリスト、ビンの数(等間隔の分割数)を指定)
・qcut
→各ビンの要素数が同じになるようにする

cutやqcutは、groupbyの引数として使え、
groupbyオブジェクトのtransformメソッドと組み合わせると、ここまで述べたような例を宣言的に実装できる(のでビジネスルールの見通しが良い...ような気がする)
"""
df = __ex(desc)

bet_cond = df['C'].between(111, 222)
cond_isin = df['C'].isin([111, 333])  # isinも便利
cut_cond_境界値 = pd.cut(df['C'], [0, 111, 222, 333])
cut_cond_ビン数 = pd.cut(df['C'], 3, precision=0)
qcut_cond_by2 = pd.qcut(df['C'], 2)
qcut_cond_by4 = pd.qcut(df['C'], 4)


print('各条件の戻り値の値~~~~~~')
print(bet_cond)
print(cond_isin)
print(cut_cond_境界値)
print(cut_cond_ビン数)
print(qcut_cond_by2)
print(qcut_cond_by4)

"""
元のDataFrame ~~~~~~~~
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333

各条件の戻り値の値~~~~~~
1_ONE       True
2_TWO       True
3_THREE    False
Name: C, dtype: bool

1_ONE       True
2_TWO      False
3_THREE     True
Name: C, dtype: bool

1_ONE        (0, 111]
2_TWO      (111, 222]
3_THREE    (222, 333]
Name: C, dtype: category

Categories (3, interval[int64]): [(0, 111] < (111, 222] < (222, 333]]
1_ONE      (111.0, 185.0]
2_TWO      (185.0, 259.0]
3_THREE    (259.0, 333.0]
Name: C, dtype: category

Categories (3, interval[float64]): [(111.0, 185.0] < (185.0, 259.0] < (259.0, 333.0]]
1_ONE      (110.999, 222.0]
2_TWO      (110.999, 222.0]
3_THREE      (222.0, 333.0]
Name: C, dtype: category

Categories (2, interval[float64]): [(110.999, 222.0] < (222.0, 333.0]]
1_ONE      (110.999, 166.5]
2_TWO        (166.5, 222.0]
3_THREE      (277.5, 333.0]
Name: C, dtype: category

Categories (4, interval[float64]): [(110.999, 166.5] < (166.5, 222.0] < (222.0, 277.5] < (277.5, 333.0]]


"""


desc = """
===========================================================
レシピ4続: maskとwhere

ここまでの分岐っぽい例で言うと、同じAならB、AでなければCの場合でも、
例外の方になんらかの重きが置かれる要件があります。
このような例は、普通に条件を記載しても良いですが、whereとmaskというメソッドがあるので、
より意図をはっきりさせるという面でもこれらを利用すると良いと思います。

・where:特定の値でない場合のみ、別の値で上書き
・mask: 特定の値の場合のみ、別の値で上書き
"""
df = __ex(desc)

CRIT = df['C'] > 111
df['D'] = df['A']
df['D'].mask(CRIT, df['X'], inplace=True)
df['E'] = df['A']
df['E'].where(CRIT, df['X'], inplace=True)

# 代入によりあらたなカラムへの挿入もできる
df['F'] = df['A'].where(CRIT)

# ref: whereについては、numpy版の方がシンタックスが直感的かもしれない
df['D_numpy_where'] = np.where(CRIT, '111より大きい', '111より大きくはない')

__p(df)


"""
インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333
アウトプット
             X  A   B    C      D      E    F D_numpy_where
1_ONE    10000  1  11  111      1  10000  NaN   111より大きくはない
2_TWO    20000  2  22  222  20000      2  2.0      111より大きい
3_THREE  30000  3  33  333  30000      3  3.0      111より大きい

"""


desc = """
===========================================================
レシピ5: applyを変換ルール、ビジネスルールを可視化するものとして捉える

DataFrame.apply()は、各行や各列に関する引数の関数での演算後のDataFrameやSeriesを戻すもの...
というのが無駄のない定義だと思いますが、ここでは、関数名や関数の定義のカタチが求められている
ビジネスルールを想起させるか、想起させるために使いたいという立場をとります。

なお、
※DataFrameのapplyメソッドでは、axis=1とすると、「lambda x:」の
xは各行のSeriesとなる。
... ので、x[カラム名]で、元のDataFrameの現在の行の当該カラムの値を取得できる。

というのは、applyのサンプル例などでapply(sum)といった例からは想像がつきにくいので、
ご存知なかった方は再確認ください。

"""
df = __ex(desc)

# 先述の例をapplyに無名関数を渡して実現する記載例
df['D'] = df.apply(
    lambda s: s['A'] if s['C'] > 111
    else s['X'], axis=1)

# Pythonの3項演算子のあえてのネストでの記述
#    3項演算子の見栄えに慣れる必要があるが、表や他の言語のswitch文ぽく見えるので、以外にわかりやすい?
df['E'] = df.apply(
    lambda s:
    s['A'] + 222 if s['C'] > 222 else
    s['A'] + 111 if s['C'] > 111 else
    s['X'],
    axis=1)

# 関数定義に切り出すスタイル --------ここから---------------
# 中身ではなく、確立されたルールなんだというところが大事という考え方にしたがったアプローチ → あ
# 確立されたルールであることと、ルールの象徴的な変数を目立たせるという気持ちでの関数定義 → い


def 業務ルール_あ(s):
    return s['A'] + 222 if s['C'] > 222 else s['A'] + 111 if s['C'] > 111 else s['X']


def 業務ルール_い(s, b):
    QQQ = b["判定カラム"]
    XXX = b["デフォルトカラム"]
    ZZZ = b["例外カラム"]
    return s[XXX] + 222 if s[QQQ] > 222 else s[XXX] + 111 if s[QQQ] > 111 else s[ZZZ]


df['あ'] = df.apply(業務ルール_あ, axis=1)

df['い'] = df.apply(
    業務ルール_い, b={'判定カラム': 'C', 'デフォルトカラム': 'A', '例外カラム': 'X'}, axis=1)
# ↑「業務ルール_い」というのがあるのね、というところと、それは、データ項目のC、A、Xあたりを軸にしたものなんだろうかという見栄えが表現できている!(個人の主観)

# どちらも今回のデータであれば、同じ結果になります。

# 関数定義に切り出すスタイル --------ここまで---------------

__p(df)

"""
インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333

アウトプット
             X  A   B    C      D      E      あ      い
1_ONE    10000  1  11  111  10000  10000  10000  10000
2_TWO    20000  2  22  222      2    113    113    113
3_THREE  30000  3  33  333      3    225    225    225



"""


desc = """
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

思いの外、代入に向けての行選択の話が膨らみました。
ここからは、Pandasのインデックスアライメントの性質を念頭に、(多少暗黙的になるのはともかく)
その分、プログラムの見栄えが、ビジネスルールの見栄えに近い記述ができるかという視点で、
Pandasの各種シンタックスを追ってみます。

===========================================================
レシピ6: インデックスアラインメント再びその1
・DataFrameやSeriesにSeriesを代入すると、インデックスの値を揃えて各行の値が代入される。
 つまり、現カラムと同じ値を元のDataFrameに列を追加して代入しようという場合には、以下の(例1)の記載で十分である。
・インデックスアライメントによるので、インデックスの値が違う左辺の行には、値が代入されない。
また、右辺に存在しないインデックスの行は欠損値となる。

→ このルールを前提に省略できる記述はあえて省略することで、見栄えが宣言的なプログラム近づけられるのでは?という期待。

"""
df = __ex(desc)

# (例1)
df['新カラムにカラムAと同じ値を設定'] = df['A']

df[['新カラムにカラムAと同じ値を設定_左右両辺にDF', '新カラムにカラムBと同じ値を設定_左右両辺にDF']] = df[['A', 'B']]

df['E_欠損値や代入されない例1'] = pd.Series({'1_ONE': 'あ', '2_TWO': 'い', '4_FOUR': 'え'})
df['F_欠損値や代入されない例2'] = pd.Series(
    {'2_TWO': 'イー', '3_THREE': 'ウー', '4_FOUR': 'エー'})

__p(df)

"""
レシピ6の出力

インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333

アウトプット
             X  A   B  ...  新カラムにカラムBと同じ値を設定_左右両辺にDF  E_欠損値や代入されない例1  F_欠損値や代入されない例2
1_ONE    10000  1  11  ...                        11               あ             NaN
2_TWO    20000  2  22  ...                        22               い              イー
3_THREE  30000  3  33  ...                        33             NaN              ウー

[3 rows x 9 columns]

"""


desc = """
===========================================================
レシピ6続:インデックスアラインメント再びその2
・列の値の条件で、行選択した場合のSeries
 および これらの複数のSeriesを(一見以外だが)横方向にconcatで結合
  ↓ 
・インデックスのリストが揃っていることを前提にデータフレーム同士を代入することで、演算した値のカラムを元のデータフレームに追加したのと同じ効果
"""
df = __ex(desc)


# Series視点で演算を考える
# sx、sa、sbはそれぞれ別の行のみ(ただし、元のDataFrameのインデックスの値はそのまま引き継ぎしている)のデータになっていることに注目。

sx = df[df['C'] == 111]['X']
sa = df[df['C'] == 222]['A']
sb = df[df['C'] == 333]['B']

# 別の行のデータをconcatする。→ 何が起きる???
print('SeriesのconcatによるDataFrameの内容')
xab_df = pd.concat([sx, sa, sb], axis=1, sort=True)

# 無事、インデックスを軸に結合された、つまり、3カラムのDataFrameが生成された。
print(xab_df)

# 代入したいカラムの名称を下記の書式でオペランドの左に記述。 ここにここまでで生成したxab_dfを代入。
print('DataFrameへのDataFrame代入によるカラム追加相当')
df[['addX', 'addA', 'addB']] = xab_df

# addXなどにそのまま設定された!!
print(df)


"""
レシピ6続の出力

インプット
             X  A   B    C
1_ONE    10000  1  11  111
2_TWO    20000  2  22  222
3_THREE  30000  3  33  333


アウトプット

SeriesのconcatによるDataFrameの内容
               X    A     B
1_ONE    10000.0  NaN   NaN
2_TWO        NaN  2.0   NaN
3_THREE      NaN  NaN  33.0

DataFrameへのDataFrame代入によるカラム追加相当
             X  A   B    C     addX  addA  addB
1_ONE    10000  1  11  111  10000.0   NaN   NaN
2_TWO    20000  2  22  222      NaN   2.0   NaN
3_THREE  30000  3  33  333      NaN   NaN  33.0



この例では、df['C'] == 111 の条件部分が簡単であるため、次の例をいたずらに複雑にしたにすぎない面もある。

df['addX'] =  df[df['C'] == 111]['X']
df['addA'] =  df[df['C'] == 222]['A']
df['addB'] =  df[df['C'] == 333]['B']

ただし、上記のように、並べた時に差異が目grepで分かるようなものならまだしも、
条件が複雑な場合は、先述のapplyを変換ルール(のポイント)を可視化するアプローチと組み合わせたり、
条件に該当する部分をBooleanインデックスの変数や配列に入れて名付けの上、抽象化するといいんじゃないか...という気がしてきませんか?
(少し例が弱いですが、力尽きたのでここまで....)

"""

文学論のような/文章術のような何か

冒頭の図にあげた例を念頭において、私の思う、Pandasで可能なシンプルな記法のあるある事例を前項であげてみました。

前項のようなパターンに目をならしていただいた上で、それらを下敷きに見栄えを整えるためのプラクティスを 以下に述べてみます。

STEP1: トランスフォームのカタチを見定める

まず今対面している問題が、冒頭に引用の図のex1、2、3に近いと感じたら、そのものズバリのPandasの定石の記述のうち、自分の好きなスタイルで端的に記述しよう。 というか、おそらくそんなに難しく考えずに自分の好きなスタイルで手が動くだろう。

STEP2: 定石と標準化と名付け

ただし、可能なら、Booleanインデックスを変数化して、条件に名前をつけてみると(つけた名前がそれなりに適切な場合に限るが)後から見て見通しが良いだろう。 特に、主条件と反転する条件の2条件で編集方法が違うという場合は、主条件を変数化したものを反転させる記述で実装しよう。

STEP3: locの使い所、右辺と左辺のバランス

なお、ex1、2、3に近いと感じて、定石記述で実装した際、自分の好きなスタイルが、locを使うものだとして、locより右側の記述が長いと感じるようであれば、列名指定の方式に見直したらどうなるか確認しよう。 あるいはloc周辺の記述を代入の左辺から右辺に移動させるとどうなるか。ただし、この場合はやはり列名指定にした方がすっきりする傾向がある。 つまるところ、loc記述は、ぼちぼち分類の数が多く、右辺が小さくなり、これらを並べた際に、プログラムのステートメントの並びが表のような見栄えになる場合に向くが、逆に、それ以外は向かない気がする。なお、この「見栄えが表」というものも、大きな表になりすぎる場合は、表の固定部分は静的な関数に、それ以外はパラメータ用の定数配列などにした方が良い場合もある。また、そもそも表が綺麗なだけで、正しくビジネスルールを分類できているか点検した方が良い。

STEP4: インデックスアライメント

また、インデックスアライメントの性質を活かして、明示的に指定しなくても動作するものは、よりタイプ数が少ない記述にしても良い。 (この方向性は良くない場合もあるが、Pandasの場合は、別の意図が感じられてしまう(がその意図が想像つきにくい)明示的な各種指定よりも インデックスアライメントの性質にはのっかった方が良いと思う。)

STEP5: 条件部分や追加列が複数あるなど複数項目に関連する場合

ex4なら、applyを考えて、無名関数で実現できるか考えよう。違和感を感じたら次に述べるex5風の例にならおう。

ex5のパターンの場合(絵面がそのようにイメージできた場合) 次のパターンでしっくりくるか確認しよう。


def ビジネスルールX(1行の列ごとのSeries):
    略


def ビジネスルールY(1行の列ごとのSeries):
    略


df['新カラムA'] = df.apply(ビジネスルールX, axis=1)
df['新カラムB'] = df.apply(ビジネスルールY, axis=1)

SUB1: ビジネスルール関数

もし ビジネスルールXやYの関数が複雑になるなら、あるいは違和感を感じたら、 ・あえて3項演算子のネストにすることでシンプルになるか確認。 ・ビジネスルールXの内容がYでも既視感があれば、あるいは同じビジネスルールと見なせそうならば、引数で切り替えれるか確認。  あるいは、共通部分を抜き出して、サブビジネスルールCommonを作成。 ・Seriesを意識しなくて良い判定処理などは、切り出して共通化しよう。名前をつけることが重要。ぴったりの名前を見つけられるかが問題を正しく認識できているかの目安。

SUB2: そもそもを疑うかあきらめか

上記でより複雑になりそうだったら、あるいは度を過ぎた共通化や標準化、汎用化になるなどパラメータだらけの関数が増えたりする匂いを感じたら、そもそもインプットのDataFrameが良くない可能性を疑っても良い。例えば、共通的な前処理、ビジネスルールとは関係ないレイヤーのデータ整備の処理を通した方が良い。 データ整備の結果、改めてex5のカタチになるか、ex3あたりのカタチに生まれ変わるかもしれない。

が、そこまで難しそうな場合は、メイン処理は極めてシンプルな上記の見せかけにしておき、apply用の関数にテキトー(適切でも適当でもない、テキトー)な名前を つけるとともに、コメントに「不具合が出た場合にリファクタリングすることとし、verX.Xではこの内容で凍結する整理とした...」とか残して、テストコードを通過させることに徹することにしよう。

おまけ:応用編

上記で述べたような主張を念頭に、DAGの処理をPandasで書いてみました。

コメントが多くてその分逆に見にくく(醜く)なっているのは、本記事のタイトルに反しますが、ご容赦ください。

参考文献

pandasクックブック ―Pythonによるデータ処理のレシピ―

Pythonデータ分析/機械学習のための基本コーディング!  pandasライブラリ活用入門 (impress top gear)