ElasticsearchでGEO系クエリで遊びます。ついでにfunction_scoreも勉強してみます。

Elasticsearch GEO系クエリとこの記事の概要

GEO系クエリにはいくつかありますが、さくっと試しやすいのが、 geo_bounding_boxとgeo_distanceクエリです。

前者は、検索したい四方の北西と南東の座標を検索条件にあたえて、そのBOX内の座標を持つドキュメントを検索します。

つまり、四角の検索です。

後者は、中心点の座標と距離を与えて検索します。

つまり、円形の検索です。

ここでは、前者を中心に試してみました。

また、距離による並べ替えなど、function_scoreなどスコアリング、加えてPainless Scriptによる検索時の動的フックも少し試して見ています。

今回、私が動作確認したlasticsearchのバージョンは6.4です。

www.elastic.co

ここでは紹介しませんが、ポリゴンで検索できたりといったこともできます。 また、geo_shapeクエリでは、「Spatial Relations/ Spatial Strategies」というオプションがあり、INTERSECTS、DISJOINT、WITHIN、CONTAINSの切り替えができるようです。 これらはつまるところ、なんらかの「検索条件の枠」と検索対象のドキュメント側の「地域の枠」どおしが、含まれるか、重なっているか...などによってマッチするかどうかの扱いをコントロールできるようです。 私は試していませんが、そういうユースケースもしばしばあると思いますし、SQLでは実現しにくい検索条件なので非常におもしろいですよね。((ちょっとパフォーマンスが気になりますね))

検索データの登録

とりあえずkibanaのDevToolsなどで、「loc」フィールドが geo_pointタイプになるように設定

PUT ginza
{
    "mappings": {
        "_doc": {
            "properties": {
                "loc": {"type": "geo_point"}
            }
        }
    }
}

東京メトロ銀座線のデータをバルクロードする。 *1

あと、銀座線はこちら。

www.tokyometro.jp

銀座線は渋谷を起点に、おおよそ西から東に向けて走っています。 ↓ https://www.google.co.jp/maps/search/%E9%8A%80%E5%BA%A7%E7%B7%9A/@35.6106894,139.6903769,12z/data=!3m1!4b1

今回用いるデータのバルクロードの例

POST ginza/_doc/_bulk
{"index":{"_id":1}}
{"id":1,"name":"渋谷","loc":[139.701238,35.658871], "x":5,"y":3 }
{"index":{"_id":2}}
{"id":2,"name":"表参道","loc":[139.712314,35.665247], "x":2 }
{"index":{"_id":3}}
{"id":3,"name":"外苑前","loc":[139.717857,35.670527], "x":0 }
{"index":{"_id":4}}
{"id":4,"name":"青山一丁目","loc":[139.724159,35.672765], "x":2 }
{"index":{"_id":5}}
{"id":5,"name":"赤坂見附","loc":[139.737047,35.677021], "x":4 }
{"index":{"_id":6}}
{"id":6,"name":"溜池山王","loc":[139.741419,35.673621], "x":3 }
{"index":{"_id":7}}
{"id":7,"name":"虎ノ門","loc":[139.749832,35.670236], "x":0 }
{"index":{"_id":8}}
{"id":8,"name":"新橋","loc":[139.758432,35.667434], "x":3, "y":2 }
{"index":{"_id":9}}
{"id":9,"name":"銀座","loc":[139.763965,35.671989], "x":2 }
{"index":{"_id":10}}
{"id":10,"name":"京橋","loc":[139.770126,35.676856], "x":0 }
{"index":{"_id":11}}
{"id":11,"name":"日本橋","loc":[139.773516,35.682078], "x":2 }
{"index":{"_id":12}}
{"id":12,"name":"三越前","loc":[139.773594,35.687101], "x":2,"y":1 }
{"index":{"_id":13}}
{"id":13,"name":"神田","loc":[139.770899,35.693587], "x":0, "y":1 }
{"index":{"_id":14}}
{"id":14,"name":"末広町","loc":[139.771713,35.702972], "x":0 }
{"index":{"_id":15}}
{"id":15,"name":"上野広小路","loc":[139.772877,35.70768], "x":2 }
{"index":{"_id":16}}
{"id":16,"name":"上野","loc":[139.777122,35.711482], "x":3,"y":2 }
{"index":{"_id":17}}
{"id":17,"name":"稲荷町","loc":[139.782593,35.711273], "x":0 }
{"index":{"_id":18}}
{"id":18,"name":"田原町","loc":[139.790897,35.709897], "x":0 }
{"index":{"_id":19}}
{"id":19,"name":"浅草","loc":[139.797592,35.710733], "x":2, "y":1 }

geo_bounding_boxによるGEO検索

前セクションでインデックスに登録した銀座線の全部の駅を含むようなBOXの頂点ですが、

北西

"35.711482,139.701238"

南東

"35.658871,139.797592"

となりますね。

なので、ちょっとだけ余裕を持った

北西 35.712,139.7 南東 35.657,139.798

の範囲を検索してみます。

POST ginza/_doc/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "loc": {
              "top_left": "35.712,139.7",
              "bottom_right": "35.657,139.798"
            }
          }
        }
      ]
    }
  }
}

↓ 

登録した19件が返ってきました。
(ちなみに、全てscoreは0)

「_geo_distance」プロパティを使った中心点からのソート

続いて、渋谷駅の座標を中心点にして、ソートします。

つまり、目論見どおりであれば、銀座線の始発から終点の浅草駅まで路線の並び順にソートされるハズ。

POST ginza/_doc/_search
{
  "size":100,
  "query": { "match_all" : {}},
    "sort": [
    {
      "_geo_distance": {
        "loc": "35.658871,139.701238",
        "order": "asc"
      }
    }
  ]
}  

↓ 戻り値イメージ

{
  "hits": {
    "hits": [
      {
        "_source": {
          "id": 1,
          "name": "渋谷"
        }
      },
      {
        "_source": {
          "id": 2,
          "name": "表参道"
        }
      },
      {
        "_source": {
          "id": 3,
          "name": "外苑前"
        }
      },
      {
        "_source": {
          "id": 4,
          "name": "青山一丁目"
        }
      },
      {
        "_source": {
          "id": 5,
          "name": "赤坂見附"
        }
      },
      {
        "_source": {
          "id": 6,
          "name": "溜池山王"
        }
      },
      {
        "_source": {
          "id": 7,
          "name": "虎ノ門"
        }
      },
      {
        "_source": {
          "id": 8,
          "name": "新橋"
        }
      },
      {
        "_source": {
          "id": 9,
          "name": "銀座"
        }
      },
      {
        "_source": {
          "id": 10,
          "name": "京橋"
        }
      },
      {
        "_source": {
          "id": 11,
          "name": "日本橋"
        }
      },
      {
        "_source": {
          "id": 12,
          "name": "三越前"
        }
      },

ちなみに、応答の「"sort"」の中に、メートル距離らしきものが入ってきています(ので、これを距離と見てもよさそうです)。

あと、本格的にいろいろ考える場合は、sortのところについて次の公式Rの「ignore_unmapped」のあたりの解説も確認しておきましょう。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html#geo-sorting

また、そもそもこの公式Rの続きのところのscript_based_sortingのところにあるように、Painless Scriptでソートの条件を指定できます。 話の流れの都合でここでは述べませんが、後述のPainless Scriptの例などを使うと、「_geo_distance」をもっとひねったカスタムな条件のソートもできそうですね。

前2つを、組み合わせてみます。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "loc": {
              "top_left": "35.712,139.7",
              "bottom_right": "35.657,139.798"
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "loc": "35.658871,139.701238",
        "order": "asc"
      }
    }
  ]
}

↓
結果は略

距離の取得(script_fields、arcDistance、planeDistanceの利用)

距離によるソートはしないものの、検索の中心点からの距離が欲しい、というストーリーで、これらを個別に取得してみます。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
            "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].arcDistance(35.658871,139.701238)"
         }},
      "p_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].planeDistance(35.658871,139.701238)"
         }}
 },
 "_source":["name","loc"]
}

なお、arcDistance、planeDistanceというのは、painlessのGEO系のAPIです。

名前からなんとなくお察しします(手抜き)

https://www.elastic.co/guide/en/elasticsearch/painless/7.0/painless-api-reference.html

上記クエリの例だと、a_dist、p_distというフィールドに距離が取得できます。

        "_source": {
          "loc": [
            139.701238,
            35.658871
          ],
          "name": "渋谷"
        },
        "fields": {
          "p_dist": [
            0.001059014854358351
          ],
          "a_dist": [
            0
          ]
        },
        "sort": [
          1
        ]
      },
      {
        "_index": "ginza",
        "_type": "_doc",
        "_id": "2",
        "_score": null,
        "_source": {
          "loc": [
            139.712314,
            35.665247
          ],
          "name": "表参道"
        },
        "fields": {
          "p_dist": [
            1226.3385782660855
          ],
          "a_dist": [
            1226.3385739214464
          ]
        },

渋谷が0、表参道が1.2kmぐらいというところなのであっているのかな。

painlessで距離取得の補足事項

ここで次のドキュメントをインデックスに追加します。

座標情報無しですね。....

POST ginza/_doc/_bulk
{"index":{"_id":20}}
{"id":20,"name":"惑星プロメシューム"}

上記のデータを追加したのち、先ほどと同様に、(全件取得しつつ)取得できた駅への渋谷駅からの距離を取得します。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
            "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].arcDistance(35.658871,139.701238)"
         }},
      "p_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].planeDistance(35.658871,139.701238)"
         }}
 },
 "_source":["name","loc"]
}

↓

一応クエリは返ってきますが、次のfailuresを含みます。

    "failures": [
      {
        "shard": 2,
        "index": "ginza",
        "node": "NHxID4CmSDa21PyG_F0kHg",
        "reason": {
          "type": "script_exception",
          "reason": "runtime error",
          "script_stack": [
            "org.elasticsearch.index.fielddata.ScriptDocValues$GeoPoints.arcDistance(ScriptDocValues.java:501)",
            "doc['loc'].arcDistance(35.658871,139.701238)",
            "          ^---- HERE"
          ],
          "script": "doc['loc'].arcDistance(35.658871,139.701238)",
          "lang": "painless",
          "caused_by": {
            "type": "null_pointer_exception",
            "reason": null
          }
        }
      }
    ]

つまるところ、座標が無い、先ほど追加したid=20のレコードが処理対象になってしまうみたいですね。

では、painless scriptでnull判定を行ってエラーにならないようにしましょう。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
      "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "if(doc['loc'].value != null ){ return doc['loc'].arcDistance(35.658871,139.701238) } else{ return null }"
         }}
 },
 "_source":["name","loc"]
}

↓

結果は略するが、エラーは解消。

なお、公式だと、この類のものはsize()が0かどうか判定するという例が記載されています。

https://www.elastic.co/guide/en/elasticsearch/painless/7.0/painless-examples.html#_missing_values

渋谷駅から近い順に加点する

function_scoreおよびそれの実際の設定であるfunctionsを使って加点の指定を行います。

なお、元情報が数値や緯度経度、座標に関しては、いずれも中心点にあたるものからの「距離」が遠ざかるほど、減点されるというスコアリングが有用です。

また、Elasticsearchのクエリとしても、実際に常套句として「減衰関数」のfunctionクエリが用意されています。

下記の例では、「gauss」という減衰関数(decay function)を使っています。

多分ガウス関数などで見られるS字の曲線そのものだと思うのですが、中心点にある程度近いものは減点がなだらか、ある値周辺を境にしばらく急な傾きで減点が続いて、ある値周辺を境に再び減点がなだらかになります。

つまるところ、例えば

徒歩で歩ける範囲は、

3分の距離と5分の距離はそれほど違いがないと感じるものの

10分を超えると少しめんどくささい気持ちが加速して、

逆に30分以上は、歩くつもりはない(ので興味がない... もしくはそれでも向かわなければならないモチベーションがあるなら、車で移動するのでその場合は徒歩40分も60分も車であれば大差ない...)

という感ににそっていると思います。

減衰関数は、他にもlinearやexpがありますが、私見ではgaussが一番、上記のユースケースにそっているような気がします。

詳しくは、公式の

Function Score Query | Elasticsearch Reference [7.0] | Elastic

のセクションの最後の方にある、「decay」「scale」「offset」「origin」の説明も兼ねた関数のグラフの画像がわかりやすいと思います。

で、その減衰関数を使った距離加点(中心点から離れると減点)の例です。

POST ginza/_doc/_search
{
  "size": 100,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "match_all": {}
            }
          ],
          "filter": [
            {
              "geo_bounding_box": {
                "loc": {
                  "top_left": "35.712,139.7",
                  "bottom_right": "35.657,139.798"
                }
              }
            }
          ]
        }
      },
      "boost": "1",
      "boost_mode": "multiply",
      "score_mode": "sum",
      "functions": [
        {
          "gauss": {
            "loc": {
              "origin": "35.658871,139.701238",
              "offset": "0km",
              "scale": "5km",
              "decay": 0
            }
          },
          "weight": 1
        }
      ]
    }
  },
  "script_fields": {
    "a_dist": {
      "script": {
        "lang": "painless",
        "source": "doc['loc'].arcDistance(35.658871,139.701238)"
      }
    }
  },
  "_source":["name"]
}


↓ 結果

{
  "hits": {
    "hits": [
      {
        "_score": 1,
        "fields": {
          "a_dist": [
            0.001059014854358351
          ]
        }
      },
      {
        "_score": 0.7549775,
        "fields": {
          "a_dist": [
            1226.3385782660855
          ]
        }
      },
      {
        "_score": 0.60371536,
        "fields": {
          "a_dist": [
            1983.4067075198245
          ]
        }
      },
      {
        "_score": 0.48381922,
        "fields": {
          "a_dist": [
            2583.4874809390394
          ]
        }
      },
      {
        "_score": 0.23820671,
        "fields": {
          "a_dist": [
            3812.779265059126
          ]
        }
      },
      {
        "_score": 0.20415527,
        "fields": {
          "a_dist": [
            3983.2068896046344
          ]
        }
      },
      {
        "_score": 0.08726448,
        "fields": {
          "a_dist": [
            4568.245932624003
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            8035.270236780703
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            10439.71264168335
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            5253.984620891842
          ]
        }
      },
     ..........


なお、script_fieldsの「a_dist」は、ここでは加点とは関係ありません。

これがあると検索結果に距離が含まれるので、実際の「減点傾向」がよくわかるためです。

また、boost、boots_mode、score_modeは、(説明がめんどくさいので)ここではおまじないだと思ってください。 (本バージョンのElasticsearchだとおそらくデフォルト設定と同じなので指定しなくても同じ動きになります。)

ここでは、geo検索まわりの説明に絞っているのでそれほど意味が感じられませんが、他の加点要素との組み合わせのパラメータになります。

これらのパラメータについては、詳しくは、次のページの最初のセクションをごらんください。

www.elastic.co

さて、再びGEO検索目線み戻って、追加の解説をします。

減衰関数(ここではgauss)のところにoffset、scale、decayというサブプロパティを設定しています。

これらは、expやlinearでも共通設定項目です。

gauss、exp、linearそれぞれの性質によりますが、いずれも用いたとしても、このoffset、scale、decayのような値を設定した場合、中心点は1点として、5kmぐらいまで減点して、5km離れたあたり以降は全て0点とするようなイメージの設定です。

あえて数式のことを考えなければ、ただし、先述リンク先のグラフの画像のイメージが頭にあればという前提ですが、「decay」は0点にするつもりの0を設定するイメージで0よりちょっと大きい値を設定するという間に合わせ理解でも「使うだけなら」問題ないかと思います。*2

function_scoreによるgeo系検索でぼちぼちあるあるなスコアリング設定の例

冒頭にインデックスにバルクロードしたデータに「x」と「y」というプロパティがありました。

これは、その駅で乗り換えられる路線数です。「x」がJR・私鉄の合計、「y」がJRの乗り換え可能路線数です。

この値から連想して次のような加点を考えてみます。

  1. 乗り換えられる路線が多い駅は加点する。
  2. JRに乗り換えられる駅はさらに加点する。ここで、JRに乗り換えられない駅は加点しない。
  3. 乗り換えられる路線が3つ以上の駅は、さらにボーナス加点する。

および、そもそも前述の例と同様に渋谷駅から近い方の駅を減衰関数で加点する。

それぞれ、script_score、field_value_factor、(function_scoreの)filterという仕掛けを使います。 (script_scoreはちょいと手抜きです。ポイントはscoring コンテキストでの、painless scriptが使えるというところです。)

つまるところ、渋谷駅から近い方が嬉しいが、近い駅で乗り換え選択肢が少ないよりは、乗り換えの選択が多い駅を優先(ただしあんまり遠いのは嫌)のような例になります。

↓実際のクエリは下記のとおり。

POST ginza/_doc/_search?filter_path=*.*._s*,*.*.f*
{
  "size": 100,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "match_all": {}
            }
          ],
          "filter": [
            {
              "geo_bounding_box": {
                "loc": {
                  "top_left": "35.712,139.7",
                  "bottom_right": "35.657,139.798"
                }
              }
            }
          ]
        }
      },
      "boost": "1",
      "boost_mode": "multiply",
      "score_mode": "sum",
      "functions": [
        {
          "gauss": {
            "loc": {
              "origin": "35.658871,139.701238",
              "offset": "0km",
              "scale": "5km",
              "decay": 0.001
            }
          },
          "weight": 1
        },
        {
          "script_score": {
            "script": {
              "source": "doc['x'].value"
            }
          }
        },
        {
          "field_value_factor": {
            "field": "y",
            "factor": 0.8,
            "missing": 0
          }
        },
        {
           "filter": { "range": { "x": { "gte": 3 } }},
            "weight": 2
        }
      ]
    }
  },
  "script_fields": {
    "a_dist": {
      "script": {
        "lang": "painless",
        "source": "doc['loc'].arcDistance(35.658871,139.701238)"
      }
    }
  },
  "_source": [
    "name"
  ]
}


↓ 結果: 新橋や上野など節目の駅が加点によりせり上がってきている(...と言えると思う)


{
  "hits": {
    "hits": [
      {
        "_score": 10.4,
        "_source": {
          "name": "渋谷"
        },
        "fields": {
          "a_dist": [
            0
          ]
        }
      },
      {
        "_score": 6.6004868,
        "_source": {
          "name": "新橋"
        },
        "fields": {
          "a_dist": [
            5253.984537158694
          ]
        }
      },
      {
        "_score": 6.6,
        "_source": {
          "name": "上野"
        },
        "fields": {
          "a_dist": [
            9010.821109908235
          ]
        }
      },
      {
        "_score": 6.0180106,
        "_source": {
          "name": "赤坂見附"
        },
        "fields": {
          "a_dist": [
            3812.779221045536
          ]
        }
      },
      {
        "_score": 5.0124764,
        "_source": {
          "name": "溜池山王"
        },
        "fields": {
          "a_dist": [
            3983.2068434617145
          ]
        }
      },
      {
        "_score": 2.8000004,
        "_source": {
          "name": "三越前"
        },
        "fields": {
          "a_dist": [
            7250.651361098846
          ]
        }
      },
      {
        "_score": 2.8,
        "_source": {
          "name": "浅草"
        },
        "fields": {
          "a_dist": [
            10439.711719992803
          ]
        }
      },
      {
        "_score": 2.6599808,
        "_source": {
          "name": "表参道"
        },
        "fields": {
          "a_dist": [
            1226.3385739214464
          ]
        }
      },
      {
        "_score": 2.1581507,
        "_source": {
          "name": "青山一丁目"
        },
        "fields": {
          "a_dist": [
            2583.487466760376
          ]
        }
      },

以上で終わりです

あとで、Painless Scriptのその他読みこなしておくと便利な公式リファレンスへのポインタを自分用に貼り付ける予定。

*1:駅データ.jpのフリー版データの銀座線の駅一覧を利用させてもらいました

*2:つまり私のことか!

駅データ.jpをサンプルに使ったElasticsearchのGeo検索のクエリ例、事前準備のPandas、Pyprojでのデータ加工、Pythonクライアントでのバルクロード、Pythonクライアントでの検索、

はじめに

駅データ.jpという駅の路線データおよび緯度経度の座標を管理してありフリーでも利用可能な*1データが提供されています。

www.ekidata.jp

今回駅データ.jpのデータについて、次のチュートリアル(ひとまず動くサンプルコードを動かしてみるの意)として良さそうな例だったので、無料データをインプットにサンプルコードをまとめてました。

  • ElasticsearchのGeo検索(のクエリの形式)
  • Elasticsearchの公式Pythonクライアントライブラリを使った、バルクロード、検索の例
  • Pandasのデータ変換
  • Pythonの緯度経度を扱う有名ライブラリであるPyprojで2点間の距離を求めてみる

サンプルコード

1. 駅データ.jpデータをJSONに変換しやすいCSVに編集

元ネタは正規化されたCSVデータですので、これを結合します。 (実際のところ、今回の範囲のElasticsearchを使ってみるの範囲では結合は不要です。) 距離を求めているところは、今回のElasticsearchでの検索とは直接関係ありません。 Pyprojを使った例をまぜこみたかったからです。

import pandas as pd
import numpy as np
import sys
import csv
import copy
import pyproj

sta_df = pd.read_csv('eki/station.csv')
pref_df = pd.read_csv('eki/pref.csv')
line_df = pd.read_csv('eki/line.csv')
join_df = pd.read_csv('eki/join.csv')

# join.csvは、終点用のエントリ(次の駅のコード)が存在しない仕様になっている。
# 今回、終点の駅も「次の駅までの距離」を求めたいため、終点の駅用にに対し終点の駅のひとつ前の駅を次の駅とみなしたエントリを追加する。
# 終点駅の一覧
terminal_stas = pd.DataFrame(list(set(join_df['station_cd2'].tolist()) -
                                  set(join_df['station_cd1'].tolist())), columns=['station_cd2'])

# 終点駅 -> 前の駅のデータを、join_dfの形式に合わせて生成
terminal_stas_join_df = pd.merge(terminal_stas, join_df[['line_cd', 'station_cd1', 'station_cd2']], on=[
    'station_cd2'], suffixes=('', '_r'), how='left')[['line_cd', 'station_cd2', 'station_cd1']].rename(columns={'station_cd2': 'station_cd1', 'station_cd1': 'station_cd2'})

# join_dfに追加
join_df = join_df.append([terminal_stas_join_df]).reset_index()


# DataFrameを作る
# 反対のエントリを作成
# 最初のjoin_dfにconcatする

sta_df = pd.merge(sta_df, pref_df, on=[
    'pref_cd'], suffixes=('', '_r'), how='left')
sta_df = pd.merge(sta_df, line_df, on=[
    'line_cd'], suffixes=('', '_r'), how='left')
sta_df = pd.merge(sta_df, join_df,
                  left_on=['station_cd'], right_on=['station_cd1'], suffixes=('', '_r'), how='left')

# join_dfから取得した、次の駅の緯度経度を取得
sta_df = pd.merge(sta_df, sta_df[['station_cd', 'lon', 'lat']],
                  left_on=['station_cd2'], right_on=['station_cd'], suffixes=('', '_next'), how='left')


# 使いそうな項目
columns = ['station_cd', 'station_name', 'line_cd', 'pref_cd', 'post', 'add', 'lon',
           'lat', 'e_status', 'e_sort', 'pref_name',
           'company_cd', 'line_name', 'line_name_k', 'line_name_h',
           'line_type', 'lon_r', 'lat_r', 'zoom', 'e_status_r',
           'e_sort', 'line_cd', 'station_cd1', 'station_cd2', 'lon_next', 'lat_next']


# 運営中の駅
df = sta_df[sta_df['e_status'] == 0]


def get_distance(series):
    if series.isnull()['lon_next']:
        return 0
    grs80 = pyproj.Geod(ellps='GRS80')  # GRS80楕円体
    x, y, distance = grs80.inv(
        series['lon'], series['lat'], series['lon_next'], series['lat_next'])
    return int(distance)


# 隣駅との距離を設定
df = df.assign(dist=lambda df: df.apply(get_distance, axis=1))

# ekijpall.csvを出力
df[['station_cd', 'station_name', 'lon', 'lat', 'pref_name',
    'line_name', 'dist']].to_csv('ekijpall.csv', sep='\t', header=True)

2. JSONを作成〜Pythonクライアントでバルクロード

バルクロードします。前のステップの出力ファイルであるekijpall.csvを読み込んでいます。 なお、コード中の次のくだりで、緯度経度をElasticsearchのgeo_point型のデータ登録に合うように配列形式に編集しています。

df = df.assign(location=df'lon', 'lat'.values.tolist())

from elasticsearch import Elasticsearch, helpers
import pandas as pd
import numpy as np
import json
from pandas.io.json import json_normalize
import copy

es = Elasticsearch(host='localhost', port=9200)
INDEX = "x-idx"

"""
kibanaのDevToolsなどで次のmappings設定をしておきましょう。
PUT x-idx
{
    "mappings": {
        "_doc": {
            "properties": {
                "location": {"type": "geo_point"}
            }
        }
    }
}
"""

# 事前に作り込んだ駅データ.jpのデータを読み込み
df = pd.read_csv('ekijpall.csv', sep='\t')  # , skiprows=lambda x: x > 1000)

df = df.assign(location=df[['lon', 'lat']].values.tolist())

# JSON Linesを作る
df_lines = df.to_json(
    force_ascii=False, orient='records', lines=True)

# バルクロードの準備
actions = []
for i in iter(df_lines.split("\n")):
    v_json = json.loads(i)
    actions.append({
        "_index": INDEX,
        "_type": "_doc",
        "_id": v_json["station_cd"],
        "_source": v_json
    })

# バルクロードする
#  https://elasticsearch-py.readthedocs.io/en/master/helpers.html?highlight=bulk
helpers.bulk(es, actions)

3. Elasticsearch Geo検索の例

記事を書くにあたりちょいと調べたことを盛り込みたかったのですが*2、余裕がないので、サンプルのサンプルに絞ったものに限定。

Pythonクライアントだと、コンストラクタを立ち上げて、そいつにsearchメソッドでクエリをかませると検索できるようです。

ここでは、geo_boundingとgeo_distanceを発行してみました。

from elasticsearch import Elasticsearch, helpers
import pandas as pd
import numpy as np
import json
from pandas.io.json import json_normalize
import copy


es = Elasticsearch(host='localhost', port=9200)
INDEX = "x-idx"

x = es.search(index=INDEX, body={"query":
                                 {"geo_bounding_box":
                                  {"location":
                                   {"top_left":
                                    {"lat": 36, "lon": 140},
                                       "bottom_right":
                                       {"lat": 32, "lon": 139.3}
                                    }
                                   }
                                  }
                                 })

print(x)

x = es.search(index=INDEX, body={"query":
                                 {"geo_distance":
                                  {
                                      "distance": "500m",
                                      "location": {
                                          "lat": 35.6983573,
                                          "lon": 139.7709256
                                      }
                                  }
                                  }
                                 })

print(x)


参考にさせていただいたサイト等のリンク

pyproj [いかたこのたこつぼ]

Helpers — Elasticsearch 6.3.1 documentation

Geo queries | Elasticsearch Reference [6.6] | Elastic

Elasticsearch 5系で距離を算出するscriptをpainlessで書く - Qiita

検索順位を自在に操る | Elastic

免責事項

一応他所様のデータを使った内容なので免責事項的なところをひとこと。

この記事は、実質PandasとElasticsearchの勉強メモです。 だれかの参考になればということで、○○風のデータがあれば、それをPandasでこんな加工をして、こんな感じで取り込めば、こういう活用ができるよねという技術メモをフリーハンドで記載したものです。 当たり前の話ですが念のため記載しておくと、本当にどこかで使う場合は、データ提供元が定める利用規約を確認してください。

また、PandasやElasticsearchの各種コード例ですが、ひとまず私自身も含めて初学者の方がきっかけをつかむための、見てのとおりの完成度です。 言うまでもなく、そのままプロダクトに取り込めるレベルではないですし、プロダクトと言わずもなんらかコピペ実行するなどの際は、ご注意ください。

*1:商用データもあります。商用・フリー問わず、実際の利用は提供元が示している権利関係や利用規約をご覧ください

*2:なんか勿体つけている感じですが、自分への将来の宿題としての意味...

「街区レベル位置参照情報」を使って市区町村を囲むような長方形の4角の緯度経度を算出する体でのPandasとLeafletのサンプルプログラム

はじめに

行政が公開している「街区レベル位置参照情報」というデータを使って、↓こんな感じで、ある市区町村を囲むような4角形の矩形をあぶりだせないか試してみました。

... という体裁をとった、PandasとLeafletのサンプルプログラムを動かしてみた・やってみた記事です。

f:id:azotar:20190326211747p:plain

nlftp.mlit.go.jp

入力データのダウンロード

上記のページから、位置参照情報ダウンロードサービスのリンクを辿って次の ページに行ってください。

位置参照情報ダウンロード

ここから、何画面か、データをダウンロードしたい地域と欲しいデータが「街区レベル」か「大字・町丁目レベル」かを選択するかをいく通りかの方法で選べる画面遷移です。

画面遷移がちょっと説明が難しいのと最後に同意を求められるので、直リンクはしません。

ただ、どのルートで選んでも最終的には、ある地域一帯のデータがダウンロードできる画面に行くようなので、そこで対象としている地域の「街区レベル」のデータをダウンロードしてください。

※ファイルはSJISです。変換コマンドなどでutf8に変換してください。ここでは、utf8に変換したものとして説明を続けます。

ダウンロードしたデータを集計

「街区レベル位置参照情報」は、その地域の全ての街区レベルの住所の代表地点の緯度経度を保持しています(いるようです)。詳しくは提供元の仕様書やダウンロード時に同梱のhtmlファイルに記述がありますのでそちらを参照ください。

ここで、このデータから、今回あぶり出したいデータ、どのようにあぶり出しするかのアイディアは次の図のようなイメージです。

f:id:azotar:20190326212713p:plain

そんなに難しい話ではないですね。

もし本気でこのデータを使うとすれば、元のデータが都合のよいものになっているかどうかが重要ですが、ここではPandasで遊んでみて、Leafletで遊んでみる目的なので、いざPandasでこのようなデータを編集してみます。

データ編集のプログラム例

import pandas as pd
import numpy as np
import sys
import csv
import copy


# 13_2017.utf8.csv はインプットデータ。ここでは東京のデータ。30MBほどなのでここでは一括で処理しましたが、少しずつためしたい場合は、read_csvのskiprowsオプションなどを利用するのが良い。
#  http: // nlftp.mlit.go.jp/isj/index.html
df = pd.read_csv('13_2017.utf8.csv',
                 low_memory=False) 


# 2つの座標の中心の座標を求める。ここではDEG形式なので多分この計算式でも良い。
def center(series):
    ss = list(map(float, list(series.sort_values())))
    return ss[0] + (ss[-1] - ss[0])/2


# 注:この指定方法はPandasの新しいバージョンではdeprecatedだが、ここでは実施内容がわかりやすいためこの指定方法
AGG_COND = {
    '緯度': {'lat_n': 'max', 'lat_c': center, 'lat_s': 'min'},
    '経度': {'lon_w': 'min',  'lon_c': center, 'lon_e': 'max'}
}

ADDRESS = ['都道府県名', '市区町村名']  # , '大字・丁目名']
GEO_LOC = ['緯度', '経度']

A_AND_G = copy.deepcopy(ADDRESS)
A_AND_G.extend(GEO_LOC)

# 今回は、市区町村レベルの囲みの長方形をあぶり出すため、ある市区町村の配下のデータでgroupbyする。
# 緯度経度の値はDEG形式だが、求めるのは最大・最小のため、ここでは数値とみなして、集計して良い。
locations = df[A_AND_G].groupby(ADDRESS).agg(AGG_COND)


def gen_latlngs(series):
    """
    DataFrameの緯度経度の集計値を「JavaScriptの配列定義の文字列」に当てはめる。
    ※この時点では意図がわかりにくいが、後のLeafletのサンプル例をアドホックひとまず動かすための仕掛け
    """
    lat_n = str(series[('緯度', 'lat_n')])
    lon_w = str(series[('経度', 'lon_w')])
    lon_e = str(series[('経度', 'lon_e')])
    lat_s = str(series[('緯度', 'lat_s')])
    lat_c = str(series[('緯度', 'lat_c')])
    lon_c = str(series[('経度', 'lon_c')])
    area = series['市区町村名'][0]
    ls = f"latlngs.push([[{lat_n},{lon_w}],[{lat_n},{lon_e}],[{lat_s},{lon_e}],[{lat_s},{lon_w}],[{lat_n},{lon_w}]]); "
    cn = f"centers.push([{lat_c},{lon_c}]);"
    ar = f"popups.push('{area}');"
    return ls + cn + ar


# 編集結果を出力
locations.reset_index().apply(gen_latlngs, axis=1).to_frame().to_csv(
    sys.stdout, index=False, quoting=csv.QUOTE_NONE, escapechar=' ')

"""
標準出力に下記のイメージのテキストがつらつら出力
 latlngs.push([[35.745558, 139.677755] , [35.745558 , 139.752208] , [35.712728000000006 , 139.752208] , [35.712728000000006 , 139.677755] , [35.745558 , 139.677755]])
 centers.push([35.72914300000001, 139.7149815])
 popups.push('○○区')
"""

Leafletで地図表示の例

前述のプログラムの出力結果をよしなに取り込んだ次のHTMLファイルを保存して、Chromeなどのモダンブラウザ(?)で開いて見てください。 (※ 実際はちょっとだけ書き換えが必要になるので、下記、HTMLファイル中の解説は確認してくださいね。)

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Leaflet/OpenStreetMap他を使った市区町村枠表示<</title>
    <!--
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css">
    <script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"></script>
    -->
    <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet/v1.4.0/leaflet.css" />
    <script src="http://cdn.leafletjs.com/leaflet/v1.4.0/leaflet.js"></script>
</head>
<body>
    <div id="mapid" style="width: 100%; height: 1200px;"></div>
</body>
<script>
    let mymap = null

    let latlngs = []
    let centers = []
    let popups = []
</script>

<!-- script src="./center_and_area_data.js"></script -->

<script>
// 前のPandasのプログラムの標準出力のテキストが、JavaScriptのスニペットなので、それをファイルにして↑のようにscript srcで読み込むか、下記↓のように貼り付け。
// ここではモダンブラウザであればHTTPサーバに配置しなくてもひとまず動かしてみる目的のものであり、もちろん、もっとちゃんとしたやり方があることには注意のこと。

 latlngs.push([[35.745558, 139.677755] , [35.745558 , 139.752208] , [35.712728000000006 , 139.752208] , [35.712728000000006 , 139.677755] , [35.745558 , 139.677755]])
 centers.push([35.72914300000001, 139.7149815])
 popups.push('○○区')

</script>

<script>
    mapInitCenter = centers[0]

    window.onload = function () {
        mymap = L.map('mapid', {
            center: centers[0],
            zoom: 13
        })
        let osm = L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
            }
        )

        let blank2 = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/blank/{z}/{x}/{y}.png',
            { id: 'blankmap', attribution: "<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>国土地理院</a>" })

        let seamlessphoto11 = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
            { id: 'blankmap', attribution: "<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>国土地理院</a>" })

        let relief12 = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/relief/{z}/{x}/{y}.png',
            { id: 'blankmap', attribution: "<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>国土地理院</a>" })

        //osm.addTo(mymap)
        blank2.addTo(mymap)
        //seamlessphoto11.addTo(mymap)
        //relief12.addTo(mymap)

        let popupObj = []
        for (let i = 0; i < centers.length; i++) {
            // 直線の色をランダムに生成する(ことにした)
            let lineStyle = {
                "color": `rgb(${~~(256 * Math.random())}, ${~~(256 * Math.random())}, ${~~(256 * Math.random())})`,
                "weight": 5
            }
            // 直線の地図のインスタンスに描く。ここでは、四角形の4点の座標について、左上、右上、右下、左下の配列を引き渡すので、この座標を結ぶような直線が引かれることになり、長方形になる。
            L.polyline(latlngs[i], lineStyle).addTo(mymap)

            // ポップアップを表示。今回やりたいことからすると必須ではないが、Leafletの練習としてやってみたの例。
            popupObj.push(new L.Popup({ 'autoClose': true }))
            popupObj[i].setLatLng(centers[i]).setContent(popups[i])
            L.marker(centers[i]).addTo(mymap).bindPopup(popupObj[i]).openPopup()
        }

    }

</script>
</html>


参考

この記事の参考書籍↓です。 また、この号の「便利帳」という章には地理関係のオープンデータのリンク集とライセンス条件等をまとめた表が載っているので、ここで紹介されているリンクを探って行くと、この記事で扱ったような編集もそもそも不要で、加えてより精細なShapeデータなども得られるかもしれません。

Interface(インターフェース) 2019年 04 月号

免責事項

一応他所様のデータを使った内容なので免責事項的なところをひとこと。

この記事は、実質pandasとLeafletの勉強メモです。 だれかの参考になればということで、○○風のデータがあれば、それをpandasでこんな加工をして、こんな感じで取り込めば、こういう活用ができるよねという技術メモをフリーハンドで記載したものです。 当たり前の話ですが念のため記載しておくと、本当にどこかで使う場合は、データ提供元が定める利用規約を確認してください。

また、各種コード例ですが、ひとまず私自身も含めて初学者の方がきっかけをつかむための、見てのとおりの完成度です。 言うまでもなく、そのままプロダクトに取り込めるレベルではないですし、プロダクトと言わずもなんらかコピペ実行するなどの際は、ご注意ください。

Pandas(もしくはPython)のオレオレイディオム

はじめに

Elasticsearchに取り込むにはそのままではちょっとアレかなという類のデータを手間をかけずにPandasでデータ変換するにはというテーマで考え事をしてみました。

「よくある例」なのかは断言できませんが、ボキャブラリーとして手札にあれば、間に合わせには悪くないのではというもののサンプルコードの例をあげています。

なお、この記事は、次の記事の親戚記事です。

itdepends.hateblo.jp

↑ この記事は、本当にただのシンタックス一覧に過ぎないので、もう少しデータ処理っぽいことのさわりになるようなことをこの記事で補足しました。

itdepends.hateblo.jp

↑ この記事で使っているテクニック(?)を、もっと短めの本記事で少し分解してみたというものになります。

EAVデータ風のデータをJSONらしい体裁にバラす

ストーリー

SQLアンチパターンのEAVパターンをさらにこじらせて、オブジェクトの配列で保持している。 なんぼなんでもと思う面もあるが、このような複数のシステムからなるデータソースのデータを固定のデータフォーマットで永続化してレポジトリに保持しているといった場合にはないわけでもないのではと思ってストーリー設定。

→ このようなデータを、シンプルなJSONオブジェクトに変換する。

※ Pandasというよりは、dict(JSON)データのネスト階層の変換の例。   (是非は別として)この類のメタな処理が、型の扱いが緩めな言語では取り扱いしやすい。(取り扱いしやすい ≒ ひとまず動かすまでのタイプ量が少ない)

DataFrame中のJSON(dict)格納データの重複確認

この例におけるイディオム

  • PandasのDataFrameのある1列にJSON(dict)を保持している場合のグループ化・重複確認
  • ※ 処理の見栄えとしては重複確認だが、ストーリーとしては、おおよそ同一データであるとして「名寄せによるデータ統合・ユニーク化」できるはずだが、そうでないものが混ざっていて、そのようなものを見つけたいという方向性。
  •  ※ Pythonにおいてのdictの比較は、元にしたJSONなどのプロパティの並び順には関係なく(もともと意味がないので当たり前だが)同じものは同じとして比較できるが、Pandasは通常の型であれば重複確認の標準関数があるが、dictの場合は不可なので、別のアイディアを活用。
  • 重複チェック(上記のとおりどちらかといえば、ユニークにできない不明データの存在チェック)時の切り分けフラグ(ここでは、has_cousinというプロパティでラベルづけすることにした)を付与するような論理。
  • 名寄せによるデータ統合を行うが、名寄せ条件に含めないあるカラムの値については、配下のレコードのうち、これこれの値があれば、それを統合データの値とする...のような「生き」の条件をforループを使わずに選定する論理の例。
  • その他、上記のようなユースケースにありがちなちょっとしたクレンジングの小品をいくつか。

関連エンティティ相当のテーブルの情報を重心側のデータを軸にしたJSONデータに変換

ストーリー

多少デフォルメして説明すると、

cust_id product_cd
111 AAA
111 CCC
111 DDD

↑このようなデータを次↓に変換します。

[
  { "cust_id" : 111, "product_cd" : ["AAA", "CCC", "DDD"] },
...
]
 

関連エンティティ相当のテーブルの情報を、1対Nの1側の情報を軸にして、1側をキーとしたJSON情報に非正規化する。

イディオム

  • pd.mergeで、コード値と表示名マスタを結合して、表示名を取得。
  • 列Xでグループ化し、グループしたレコードについて、列Yの値を配列に詰め込みする。→ そのグループ内の列Yの値のバリエーションを得る。
  • 列Xのグループ化の対象とする行は、列Zの値がxxxのもののみ(以下では、SPECIALSという定数で定義)とする、といった条件付きのグループ化とする。

関連エンティティ風のデータの前日と当日の比較

ストーリー

昨日と今日の契約期中の取引情報全件(ただし、関連エンティティ型となっており、顧客単位に名寄せする必要がある)があって、変更があった顧客のリストを抜き出す。

この例で示しているイディオム

  • 昨日と今日それぞれの顧客単位の取引の配列の一覧を作成。
  • 上記を顧客IDで完全外部結合して、左に存在するが右に存在しないものは契約終了の顧客...のように変更検知する。

JSON中のNULL項目についてはプロパティを出力しない(そのような項目を取り除く)

ストーリー

DataFrameは便利だが、項目数が多い場合、いわゆるスパースマトリックスの無駄が発生する。また、項目の中にネストされているようなデータがある場合も何かと無駄が悩ましい。 このような状況において、スパースマトリックスの問題自体は他の場に譲るとして、最終結果をJSONファイルに出力する際に、Nullの項目についてはプロパティ名を出力しないようにすることで、データをコンパクトにする...という例。

※という意味では、andasのイディオムというよりは、dict(JSONと相互変換可能なものに限定されるが、JSONの範囲であれば、ネストが複雑でも再起によってなんとかなる)の再起処理の例に該当。

以上です。

検索サイトであるあるかもしれないインデックスimport前のドキュメント標準化をざっくり試すためのPython/Pandasのイディオムメモ

はじめに

検索サイトで、Elasticsearchなどの検索エンジンにデータを雑に食わせるにあたり、この用途にPandasが手頃(Pandasはもっとリッチなことができるはずだといった話はさておき)なような気がしてきので、サンプルコードを作成してみました。

f:id:azotar:20190324170507p:plain

この記事は、次の2つの記事のちょっとした続編です。

itdepends.hateblo.jp

itdepends.hateblo.jp

特に、後者の方の記事の真ん中あたりにある、

補足3: 自前アプリでの検索用ドキュメント標準化のフレームワーク

の節あたりがやや言いっ放しになっていたので*1、主張を補強するための例として、次のコードをまとめてみたのでした。

結果、Pandasのシンタックスを覚えたその次のコーディングのイディオムっぽくなったのと、Elasticsearchのなんらかの発展に貢献できればと思ってサンプルコードをのせてみます。

Pandas/Pythonのサンプルコード

サンプルコードでのアプローチイメージ(再掲) ↓

f:id:azotar:20190324170507p:plain

インプットファイルのイメージ

  {"id": 1, "a": {"b": {"c1": [111, 222, 333], "c2":111}}, "x":"あああ", "y":"いいい", "z1":"ううう", "to_be_deleted":"消される運命", "lat":"35.01.23.456", "lon":"139.01.23.456"},
     {"id": 2, "a": {"b": {"c1": [333, 444, 555]}}, "x": "えええ",
         "y": "おおお", "z2": "かかか", "lat": "35.01.23.456", "lon": "139.01.23.456"},
     {"id": 3, "x": "えええ", "lat": "35.01.23.456", "lon": "139.01.23.456",
               "rel1": True, "rel2": "この値はクリアされる", "rel3": "この値はクリアされる"},
     {"id": 4, "x": "えええ", "lat": "35.01.23.456", "lon": "139.01.23.456",
               "rel1": True, "rel2": "この値はクリアされる", "rel3": "この値はクリアされる"},
     {"id": 5, "lat": "35.01.23.456", "lon": "139.01.23.456",
         "dumpobj": [{"one": 5, "two": 6, "three": [
             7, 8, 9], "four": None, "five": [], "six": [{"a": "100"}]}]
      }

サンプルコード

import pandas as pd
import numpy as np
import json
from pandas.io.json import json_normalize
import copy

# ---------------------  関数定義 ----------------------------

# 表示名に変換


def map2dispname(obj, mapping):
    """
    コード値を格納したobjに対して、mapping(dict型) で定義された表示名に置き換えた値を返す。
    """
    if type(obj) is float:
        # floatの場合は、処理対象外のため、便宜上、空文字列を返す。
        return ""  # TBD
    elif type(obj) is str or type(obj) is int:
        # strもしくはintの場合
        return mapping.get(str(obj), str(obj))
    elif hasattr(obj, "__iter__"):
        # 列挙型の場合(返り値はlist型に強制)
        return list(map(lambda y: mapping.get(str(y), str(y)), obj))
    else:
        return str(obj)


def series_to_arrfield(v):
    """ 
    Seriesを配列風の1つの文字列に変換
    """
    return ' '.join(map(str, v.fillna('').values))


def funny_dms_to_deg(dms_str):
    """
    その場しのぎのadhocな緯度経度形式の変換の関数(次に定義のdms_to_degを優先したので実際は未使用)
    """
    if pd.isnull(dms_str):
        return None
    else:
        dms_str_arr = [s for s in str(dms_str).replace(
            'E', '').replace('N', '').split('.')]
        deg = dms_str_arr[0] + '.' + dms_str_arr[1] + \
            dms_str_arr[2] + dms_str_arr[3]
        if deg == None:
            return None
        return float(deg)


def dms_to_deg(dms_str):
    """
    緯度経度をDMS形式からDEG形式に変換する関数
    (この例で臨場感を出すために設けたが、このロジック自体はご紹介したいものの主題ではない。)
    """
    if pd.isnull(dms_str):
        return None
    else:
        dms_str_arr = [s for s in str(dms_str).replace(
            'E', '').replace('N', '').split('.')]
        deg = int(dms_str_arr[0])
        + int(dms_str_arr[1])/60
        + (int(dms_str_arr[2]) + int(dms_str_arr[3])/1000)/3600
        if deg == None:
            return None
        return deg


dtd = dms_to_deg


def all_values(obj):
    """
    dictinary型(≒ JSON)のある階層より下の最下層のコンテンツ(value)を文字列でダンプする。
    データソースは複雑な階層のJSONだが、Elasticsearchなど検索エンジンのインデックスに取り込む際は、生データのテキストの列挙で良い...という要件の場合に、ドキュメントのプレ加工を行うことをイメージしている。
    """
    # コンセプトメモ:
    # ターゲットが、JSON由来のデータなので、定石として、シンプルな再起処理でデータを掘り下げられるハズだが、いざ必要になった際にフリーハンドで実現するのも手間なので、イディオムとして残してみた。
    # なお、dictinary型(≒ JSON)のNoneのフィールドを除去する方針とした。
    #  ※もっと簡単に実装できる気がする。そもそもライブラリやイディオムがあるような気もする。
    #  例えば、DataFrame.values()をうまくつかえばもっとシンプルな記述にできるかもしれない。

    def wrap_all_values(val):
        """
        内部関数
        # 最下層のコンテンツかどうかを判定し、文字列を返す。最下層でなければ、all_valuesを呼び出す
        """
        if val is None:
            return ""
        elif isinstance(val, str) or isinstance(val, int):
            return str(val)
        else:
            return str(all_values(val))

    DELEMITER = ','
    if obj is None:
        return ""
    vt = ""
    if isinstance(obj, dict):
        for val in obj.values():
            vt = vt + DELEMITER + wrap_all_values(val)
    elif isinstance(obj, list):
        for val in obj:
            vt = vt + DELEMITER + wrap_all_values(val)
    return vt


# JSONの「null」や空文字、空の配列のフィールドのプロパティを削除する
# -------------インプットデータやコンフィグ情報など ---------------------
# 仮想コンフィグファイル
J2J_CONFIG = [
    {"FN": "a.b.c1", "M2D": "111:NewYork, 222:California,333:Texas,444:Hawaii"},
    {"FN": "a.b.c2", "M2D": "111:NewYork, 222:California,333:Texas,444:Hawaii"},
    {"FN": "x", "CNC": True, "PSH": "newarray"},
    {"FN": "y", "CNC": True, "PSH": "newarray"},
    {"FN": "z1", "CNC": True, "PSH": "newarray"},
    {"FN": "z2", "CNC": True,  "PSH": "newarray"},
    {"FN": "z3", "CNC": True},
    {"FN": "rel1"},
    {"FN": "rel2", "PRN": "rel1"},
    {"FN": "rel3", "PRN": "rel1"},
    {"FN": "to_be_deleted", "DL": True},
    {"FN": "create_user", "DL": True},
    {"FN": "update_user", "DL": True},
    {"FN": "dumpobj", "VLS": True}
]
"""
コンフィグの考え方:

後述のインプットデータの項目名(=DataFrameの列名) が、「fn」で定義されているフィールドについて、「M2D」にdict定義風の文字列で、コード値から表示名への定義がされていれば、その項目を表示名に変換した新たな項目を派生させる。
(元ネタは、EXCELで定義された変換仕様表的なやつをイメージ。(どこかの界隈で好まれそうなアプローチですね...))
「M2D」をはじめ「cnc」「PSH」「PRN」「DL」「VLS」も変換内容やこの設定の考え方は違えど同じような発想のもの。
 ※「PRN」は、他のものとやや異なり、その項目の親フィールド名を定義している。親フィールド名を定義して何をするかは、後述の実際の処理イメージを見てください。
]


"""


J2J_CONFIG_DF = pd.DataFrame(J2J_CONFIG)

# 仮想インプットJSON(複数レコード)
df = json_normalize(
    [{"id": 1, "a": {"b": {"c1": [111, 222, 333], "c2":111}}, "x":"あああ", "y":"いいい", "z1":"ううう", "to_be_deleted":"消される運命", "lat":"35.01.23.456", "lon":"139.01.23.456"},
     {"id": 2, "a": {"b": {"c1": [333, 444, 555]}}, "x": "えええ",
         "y": "おおお", "z2": "かかか", "lat": "35.01.23.456", "lon": "139.01.23.456"},
     {"id": 3, "x": "えええ", "lat": "35.01.23.456", "lon": "139.01.23.456",
               "rel1": True, "rel2": "この値はクリアされる", "rel3": "この値はクリアされる"},
     {"id": 4, "x": "えええ", "lat": "35.01.23.456", "lon": "139.01.23.456",
               "rel1": True, "rel2": "この値はクリアされる", "rel3": "この値はクリアされる"},
     {"id": 5, "lat": "35.01.23.456", "lon": "139.01.23.456",
         "dumpobj": [{"one": 5, "two": 6, "three": [
             7, 8, 9], "four": None, "five": [], "six": [{"a": "100"}]}]
      }
     ]
)


# -------------ここから変換処理 ---------------------


"""
PRN処理 ========================
# 親削除フラグ項目がTrueならクリアする。
  良くある論理削除フラグと似たようなもので、グループ項目全体を無効にする意味のフラグがONの場合、配下の項目はユーザー画面には表示しない...という類のものは、当然Elasticsearchの検索対象、表示項目にしたくないためそれをクリアしておくというような用途をイメージ。
※いきなり最もめんどくさいパターンからになるので、他のパターンを先に見てください。
"""

# 関連するコンフィグを抜き出す。 「(親フィールド, 子フィールドの配列名一覧)」を抜き出す
df_fields_and_parent = J2J_CONFIG_DF.loc[J2J_CONFIG_DF['PRN'].str.len() > 0].groupby(['PRN'])[
    'FN'].apply(list).reset_index()

# 条件付きクリア
for idx, row in df_fields_and_parent.iterrows():
    flg_field = row['PRN']
    clear_targets = row['FN']
    # 親フィールドがTrueの行について、子フィールドの値を空文字列(この例でのクリアの扱い)に設定して代入しなおす。
    df[clear_targets] = df[df[flg_field] == True][clear_targets].apply(
        lambda v: pd.Series(['' for s in clear_targets]), axis=1)


"""
  プレクリーニング ========================
  要件上、不要な項目をこの時点で削除。欠損値の補完/項目削除。特定の値の場合は、欠損値扱いにして削除。
  #  (未実装)
"""

pass

"""
 M2D処理 ====================
 表示名に変換
"""
df_maps = J2J_CONFIG_DF.loc[J2J_CONFIG_DF['M2D'].str.len() > 0]

for idx, row in df_maps.iterrows():
    m = {i.split(':')[0]: i.split(':')[1] for i in row['M2D'].split(',')}
    fname = row['FN']
    # 表示名を設定したフィールドを、「元のフィールド名+_M2D」に派生(代入)
    df[fname + '_M2D'] = df[fname].apply(
        lambda x: map2dispname(x, m))


"""
VLS処理 ========================
JSONの配下のvalueを全てダンプする

"""
# 当該フィールド(dict型)のものについて、配下の全ての値のみ取得し、出力する
df_dump_fields = J2J_CONFIG_DF.loc[J2J_CONFIG_DF['VLS'] == True]['FN']

for idx, val in df_dump_fields.iteritems():
    fname = val
    df[fname + '_VLS'] = df[fname].apply(lambda x: all_values(x))

"""
 SH処理 ========================
当該項目の内容を、コンフィグで指定された集約対象のフィールドにアペンドする。
情報源の上流システムがパッケージソフトなどによるRDBテーブルのカラム自動生成方式であり、入力画面では複数選択可能チェックボックスだが、選択肢ごとにRDBのテーブルの1カラムとなっているようなものを、検索時にキーワードでシンプルにヒットできるようにすることをイメージした変換。
※言葉にするとややこしいので、シンプルバージョンのCNC処理を先に見た方が良い。
"""

df_fields_into_one_array = J2J_CONFIG_DF.loc[J2J_CONFIG_DF['PSH'].str.len() > 0].groupby(['PSH'])[
    'FN'].apply(list).reset_index()

for idx, row in df_fields_into_one_array.iterrows():
    array_name = row['PSH']
    fields = row['FN']
    # ビジネスルール的視点ではアペンドだが、ここでは、対象フィールドの値の配列を作ってそれを代入
    df[array_name] = df[fields].fillna('').values.tolist()


"""
 CNC処理 ========================
これの処理対象の指定があるフィールドについて、指定項目の内容を、指定の(ここではCONCAT_FIELDという名前のDataFrameの列)にアペンドする。
実際は、前項のSH処理のシンプルバージョンである。
よって、SH処理でも代用できるが、CONCAT_FIELDというマジックフィールドをビジネスルール的に意識したいことから専用の論理にした。
"""

# 所定の項目を全て取り込んだ文字列フィールド CONCAT_FIELD
batch_concat_cols = J2J_CONFIG_DF.query('CNC == True')['FN'].tolist()
batch_concat_cols = list(set(batch_concat_cols) & set(df.columns))
df['CONCAT_FIELD'] = df[batch_concat_cols].apply(series_to_arrfield, axis=1)

"""
# 緯度経度の形式変換 ======================
#  今回のストーリーとしてはそれほど意味をなさない例だが、df['xxx']への代入ではなく、DataFrame.assign()を使ってみた例。
#  コンフィグによるものではなく、項目狙い撃ちのスペシャルな変換もあるよね...という例。
"""
df = df.assign(xlon=df['lon'].apply(dtd))
df = df.assign(xlat=df['lat'].apply(dtd))
df = df.assign(location=df[['xlon', 'xlat']].values.tolist())

"""
 DL処理 =========================
# 不要なフィールドは削除する
"""
batch_del_cols = J2J_CONFIG_DF.query('DL == True')['FN'].tolist()
batch_del_cols = list(set(batch_del_cols) & set(df.columns))
df = df.drop(labels=batch_del_cols, axis=1)


# -------------ここから出力処理 ---------------------

# JSON Lineで出力

df_lines = df.to_json(
    force_ascii=False, orient='records', lines=True)

# 課題メモ:
# この場合、JSONの最大プロパティ数が多いとnullや空の配列のデータが多く出力されるスパースなデータとなってしまう。


# Elasticsearchのバルクロード用の出力のJSON Lines
# データをJSON Lines出力するだけなら、DataFrame.to_jsonで事足りるのだが、Elasticsearchのバルクロードは、アクション(正式名称は忘れた。POST先のインデックス名やCRUDのどれかなどを示すもの。)と対象レコードを1対とした繰り返し形式のため、ひとまずこんな感じで生で出力。
for i in iter(df_lines.split("\n")):
    v_json = json.loads(i)
    print('{"index": { "_id" : ' + str(v_json['id']) + '}}')
    print(i)

サンプルコードの実行結果

nullヌルだらけになってしまいましたが、これはまた。

{"index": { "_id" : 1}}
{"a.b.c1":[111,222,333],"a.b.c2":111.0,"dumpobj":null,"id":1,"lat":"35.01.23.456","lon":"139.01.23.456","rel1":null,"rel2":null,"rel3":null,"x":"あああ","y":"いいい","z1":"ううう","z2":null,"a.b.c1_M2D":["NewYork","222","Texas"],"a.b.c2_M2D":"","dumpobj_VLS":"","newarray":["あああ","いいい","ううう",""],"CONCAT_FIELD":"いいい  あああ ううう","xlon":139,"xlat":35,"location":[139,35]}
{"index": { "_id" : 2}}
{"a.b.c1":[333,444,555],"a.b.c2":null,"dumpobj":null,"id":2,"lat":"35.01.23.456","lon":"139.01.23.456","rel1":null,"rel2":null,"rel3":null,"x":"えええ","y":"おおお","z1":null,"z2":"かかか","a.b.c1_M2D":["Texas","Hawaii","555"],"a.b.c2_M2D":"","dumpobj_VLS":"","newarray":["えええ","おおお","","かかか"],"CONCAT_FIELD":"おおお かかか えええ ","xlon":139,"xlat":35,"location":[139,35]}
{"index": { "_id" : 3}}
{"a.b.c1":null,"a.b.c2":null,"dumpobj":null,"id":3,"lat":"35.01.23.456","lon":"139.01.23.456","rel1":true,"rel2":"","rel3":"","x":"えええ","y":null,"z1":null,"z2":null,"a.b.c1_M2D":"","a.b.c2_M2D":"","dumpobj_VLS":"","newarray":["えええ","","",""],"CONCAT_FIELD":"  えええ ","xlon":139,"xlat":35,"location":[139,35]}
{"index": { "_id" : 4}}
{"a.b.c1":null,"a.b.c2":null,"dumpobj":null,"id":4,"lat":"35.01.23.456","lon":"139.01.23.456","rel1":true,"rel2":"","rel3":"","x":"えええ","y":null,"z1":null,"z2":null,"a.b.c1_M2D":"","a.b.c2_M2D":"","dumpobj_VLS":"","newarray":["えええ","","",""],"CONCAT_FIELD":"  えええ ","xlon":139,"xlat":35,"location":[139,35]}
{"index": { "_id" : 5}}
{"a.b.c1":null,"a.b.c2":null,"dumpobj":[{"one":5,"two":6,"three":[7,8,9],"four":null,"five":[],"six":[{"a":"100"}]}],"id":5,"lat":"35.01.23.456","lon":"139.01.23.456","rel1":null,"rel2":null,"rel3":null,"x":null,"y":null,"z1":null,"z2":null,"a.b.c1_M2D":"","a.b.c2_M2D":"","dumpobj_VLS":",,5,6,,7,8,9,,,,,100","newarray":["","","",""],"CONCAT_FIELD":"   ","xlon":139,"xlat":35,"location":[139,35]}

念のための免責事項

この記事は、実質pandasとElasticsearchの勉強メモです。 だれかの参考になればということで、○○風のデータがあれば、それをpandasでこんな加工をして、こんな感じで取り込めば、こういう活用ができるよねという技術メモをフリーハンドで記載したものです。

また、pandasやElasticsearchの各種コード例ですが、ひとまず私自身も含めて初学者の方がきっかけをつかむための、見てのとおりの完成度です。 言うまでもなく、そのままプロダクトに取り込めるレベルではないですし、この先は、できるだけ公式の情報源をあたっていただくとか、例えば、コーディングスタイルもpythonicなコードになるように磨き上げて抱く前提です。 (つまるところ、この記事の例は、そのレベルのものではないですよという話、言い訳です。念のため。)

*1:もともと位置づけからすると日曜大工ブログなので全体的に言いっ放しなのは宿命ですが...

カジュアルなETLで利用したい人のためのPython/ Pandasミニミニミニチュートリアル

まえおき

他のスクリプト言語などの経験からPythonは雰囲気程度の読みこなしはできるけど、ちょっとした文字列中心のETLっぽいことがしたくてfor文はあまり書きたくなくて、Pandasだとそれが楽にできそうだけど、NumPyから入るのもちょっと目的が違うし、一度しか使わないかもしれないし、という人向けに、Pandasのシンタックスをまとめてみました。

特別何かの処理をするというところまでは示していないのですが、ここにあげたシンタックス例を眺めてみると、Pandasで何ができそうか察することができる?ようなものを目指しました。

f:id:azotar:20190323141339p:plain

例えばElasticsearchにデータを入れるためにちょっとデータ加工したいというレベルでPandasをかじりたい時に、見よう見まねでコピペできるぐらいのものが見つけられませんでした*1

といことで、どうせならということで、Pandasのシンタックスの基本的なものをざっくりあげてみたのでした。ぼちぼち解説は盛り込みましたが、この目的に沿った範囲のものになっていますので、いろいろツメが甘いと思いますので、興味がわいたら書籍や公式サイトをあたってください。

参考リンク

一応、公式のリンクはこちら。 pandas.pydata.org

また、私が良く参考にさせていただいているチートシートを日本語訳されているのがこちら。

qiita.com

Pandasシンタックスに慣れるためのサンプルコード

Pythonが入っている環境だと pip install numpy、 pip install pandas すれば動くレベルのものです。

import pandas as pd
import numpy as np
import sys
import copy
import json
from pandas.io.json import json_normalize


"""
本題とは関係ない解説用のログ出力用の関数。 Jyupiter利用などははしていないもので...
単なるタイプ量削減です。
"""


def msg(var):
    BAR = '----------------'
    print(BAR + str(var) + BAR)
    return True


def p(var):
    print(var)


def check_member_simply(method_name, obj):
    if method_name in list(map(str, dir(obj))):
        return True
    return False


"""
★1★

PandasはDataFrame(行と列それぞれに見出し情報付きの2次元の帳票データみたいなもの)を利用。
Series(1次元)とPanel(3次元)もあるけど、後者はテキスト系のカジュアルなデータ処理にはあまり使わなそう。
前者は、DataFrameの1行か1列分と考えるとわかりやすい。
"""
# DataFrameのコンストラクタの例
col = ['a', 'b', 'c', 'fruit']
foo = pd.DataFrame(columns=col,
                   data=[[1, 1, 1, 'apple'],
                         [2, 2, 2, 'orange'],
                         [3, 3, 3, 'lemon'],
                         [4, 4, 4, 'lemon']
                         ]
                   )

msg("DataFrame取り込み")
p(foo)

msg("列方向の項目名")
p(foo.columns)


msg("DataFrameの中身")
p(foo)

msg("DataFrameの値をlistのlistで出力")
p(foo.values)

msg("DataFrameの列へのアクセス")
p(foo.a)
msg("")
p(foo['a'])
msg("")
p(foo[['a', 'c']])
msg("")
aaa = 'a'
p(foo[aaa])

msg("applyによる宣言っぽい一括データ操作その1")
p(foo.apply(lambda x: x * 2))
# DataFrameの全てのマス目に対して2倍にしたDataFrameを返す。
# applyは掘り下げ要。NumPyを通過していない人が、Pandasの例でこの例だけ見ると実は混乱する。
# →後述します。

"""
↓ こんな結果が返ってきます。2倍ですね。

dtype: int64
   a  b  c         fruit
0  2  2  2    appleapple
1  4  4  4  orangeorange
2  6  6  6    lemonlemon
3  8  8  8    lemonlemon
"""

# applyの件は後述するものの、↓の感触を覚えておくと良い。
msg("Numpy由来のブロードキャスト")
p(foo * 2)
# 注目:前出のapplyの例と同じ結果が得られる。

# [ブロードキャスト]
# 通常の数学における行列の演算では扱えないような、行や列の数(shape)があっていないデータどうしでも簡易な記法で計算できるようにする仕掛け
#  例. 2次元の数値の配列と 1次元の数値の配列を足す。
# 逆にいうと計算できるようにするために、未定義のカラムなどの値にデフォルト値をおぎなったり、pandasが計算可能な形にもっていくことができる(4つのルール)ことが条件となる
# ↓ 参考
# https://deepage.net/features/numpy-broadcasting.html
#
#


"""
★2★
"""
msg("applyによる宣言っぽい一括データ操作その2")
p(foo.apply(lambda x: max(x)))
# →後述します。


msg("loc[行の指定,列の指定]によるアクセス")
p(foo.loc[:, :])
msg("")
p(foo.loc[:, 'a'])
msg("")
p(foo.loc[:, 'a'])
msg("")
p(foo.loc[1, 'a'])
msg("")
p(foo.loc[foo['a'] > 1, ['a', 'b']])
msg("")

msg("filterによる列名の正規表現でのアクセス")
p(foo.filter(regex='c+'))

msg("queryによる列の値が該当の条件にヒットする行の抽出。つまりselect")
p(foo.query("a==2 and b==2"))

msg("DataFrame['a'] > 1 は、列aの値が1より大きい行の、boolのSeries(行インデックス番号付き)返す")
p(foo['a'] > 1)

msg("なので、foo[foo['a'] > 1]は、selectとして機能する")
p(foo[foo['a'] > 1])

msg("条件に該当した行のみ、値を追加")

print("補足:Pythonは参照渡しでこれは普通なのかもしれないけど、PandasでETLっぽい視点でデータ操作しているとデータを潰してしまう。")
print("ここでは、DataFrame fooは触りたくないので、foo.copy()で新しいfootmpに代入する。")
print("footmp = foo.copy()")
footmp = foo.copy()
print("footmp['x'] = 1 ")
footmp['x'] = 1
p(footmp)
footmp['only_over2'] = footmp[footmp['a'] >
                              2]['b'].apply(lambda x: x * 10)
print("'a'の値が2より大きい行だけ、'b'の値を10倍して、その値を'only_over2'の列に代入")
p(footmp)


msg("複数のDataFrameの結合(SQLのJOIN、....)")
print("SQLのJOINにあたるのはDataFrame.merge")
p(pd.merge(foo, foo, left_on=["a"], right_on=[
    "a"], how='left', indicator='ind', suffixes=['_l', '_r']))


"""
参考
http://sinhrks.hatenablog.com/entry/2014/11/27/232150
"""
msg("PandasはNumPyが親なので(だからだと思うが)、こんな演算もできる")
p(foo['a'] + foo['b'])
msg("")
p(foo['a'] * foo['b'])

msg("代入もできる。Seriesどうしを演算して新たなSeriesを生成してそれを、DataFrameの1列(Series)に代入する")
footmp = foo.copy()
footmp['a+b'] = foo['a'] + foo['b']
p(footmp)

msg("複数列にアクセスするとDataFrameが戻る")
footmp = footmp[['a', 'a+b']]
p(footmp)


"""

★3★

applyとmapとapplymap
こちらの図解を参考にさせていただく
http://sinhrks.hatenablog.com/entry/2015/06/18/221747

まず、DataFrame.apply(関数, axis=0) から理解。
  ※axis=0はデフォルト扱いで省略可能
     → この例だと、各列について、その列のSeriesを「関数」に渡す。
       ここで、関数をlambdaにすると良くわからないので、理解のためには、関数を定義して、引数で渡されるSeriesをprintしてみると良い(と思った)
"""

"""
ということでこんな関数を定義
"""


def my_func(series):
    print("my_funcがapplyよりコールされました")
    print("seriesのtype")
    print(type(series))
    print("seriesのサイズ")
    print(series.size)
    print("seriesのvalues")
    print(series.values)
    print("seriesのindex")
    print(series.index)
    print("seriesをprint")
    print(series)

    # 通常は、seriesを集計するなどの演算をするが、以下、あえて固定値の10000を返してみる
    return 10000


msg("applyのからくり: DataFrame.apply()")
mf = my_func

p(foo.apply(mf))
"""
こんな感じで出力される。
a        10000
b        10000
c        10000
fruit    10000

元の列ごとに集計したSeriesが得られる。
(fruitの列は、apple、orange、lemon、lemonを取り扱うが、今回は10000を固定で返したので、ここでは、10000)
"""

msg("applyのからくり: DataFrame.apply(,axis=1)")
p(foo.apply(mf, axis=1))

"""
こんな感じで出力される。
0    10000
1    10000
2    10000
3    10000

0、1、2、3は、わかりにくいが、元のDataFrameのindexの値。
つまり、各行ごとに列の値を使って、なんらか演算したSeriesが得られる。
ここでは、各列の値は使わずに固定で10000を返したが、my_funcの中で、series['fruit']で値が取得できるので、それを使って、条件判定したり、新たな編集後の値を生成できる。



文字列をカジュアルに扱うETL風にPandasを使うなら、この用法の活用頻度は高い。例えば、カラムAとBを結合したカラムXを派生するという場合はこのaxis=1を使う。
"""

""" 
apply(lambda x: x * 2)のヒミツ

"""
#  ↑これ相当の関数はコレ↓


def my_func2(series):
    # series * 2 により、seriesに「ブロードキャスト」の演算がされて、各要素を2倍したSeriesが得られる。
    return series * 2


""" 
DataFrameにmy_func2をapplyメソッド適用
"""
p(foo.apply(my_func2))
# ↑ 縦方向のSeriesが、'a'、'b'、'c'、'fruit'の列ごとに得られるので、元のdfの全ての要素を2倍したように見えるDataFrameが得られる。


"""
★4★
   applyやmapも良いが、イテレーターが使えると手続き型ぽくて安心するので見てみます。
"""


# Series
msg("Seriesのイテレーター")
p("__iter___方式")
for val in foo['a'].__iter__():  # 実際は「in foo」 という記述で良い
    print(val)
msg("")
p("teritems方式")
for idx, val in foo['a'].iteritems():
    print(idx)
    print(val)


# DataFrame
msg("DataFrameのイテレーター")
p("__iter___方式")
for column_name in foo.__iter__():   # 実際は「in foo」 という記述で良い
    print(type(column_name))
    print(column_name)
msg("")
p("teritems方式")
for column_name, item in foo.iteritems():
    print(column_name)
    print(item)
    print(item[0])
msg("")
p("iterrows方式")
for idx, row in foo.iterrows():
    print(idx)
    print(row)
    print(row[0])

for row in foo.itertuples():
    print(row)


msg("dropによる列の削除。ここでは略しているが破壊的に削除するオプション(引数)もある")
p(foo.drop('b', axis=1))

msg("select_dtypesの活用。説明略。")
print(foo.select_dtypes(include=['int']).apply(lambda x: x.tolist()))
print(foo.select_dtypes(include=['int']).apply(lambda x: x))

print(foo.select_dtypes(include=['int']).values)

print(foo.select_dtypes(include=['int']).apply(lambda x: x.values[3] * 2))

print(foo.select_dtypes(include=['int']).apply(
    lambda x: ','.join(map(str, x.values))))

"""

★5★

GROUP BY 

"""


foogb = foo.groupby(['fruit'])

p(type(foogb))
#  ↓ 出力
# <class 'pandas.core.groupby.DataFrameGroupBy' >
#  ※ GroupByデータ型(?)

msg("")
p(foogb['a'])
#  ↓ 出力
# <pandas.core.groupby.SeriesGroupBy object at 0x109f69d68 >

msg("")
p(type(foogb['a']))
#  ↓ 出力
# <class 'pandas.core.groupby.SeriesGroupBy' >


msg("groupbyと基本的な使い方。まずは雰囲気をつかむ。ここではfruit列でグループ化")

p(foogb.groups)
msg("")

p(foogb.size())

msg("")

p(foogb.sum())
msg("")

p(foogb.mean())
msg("")

p(foogb.agg(np.mean))
msg("")

p(foogb.agg('min'))
msg("")

p(foogb.agg(min))

msg("groupby だんだん分からなくなってくる")

print(foogb.agg([np.mean, 'min', 'max', lambda x: max(x) - min(x)]))
msg("")

print(foogb.agg({'a': pd.np.mean}))
msg("")

print(foogb.apply(lambda d: (d.a * d.b).sum()))
msg("")

print(foogb.apply(lambda d: d.name))
msg("")

msg("groupby のイテレーション")
for name, group in foogb:
    print('--name--')
    print(name)
    print('--group--')
    print(group)
    print('--type(group)--')
    print(type(group))

msg("")

for name, group in foogb:
    print(name)
    gl = group['a'].tolist()
    print(gl)
    print(name + ':' + ','.join(map(str, gl)))

msg("")


msg("グループ化の条件を関数で与えることができる")
# DataFrame fooのカラムaの値が、奇数か偶数かを1、0で保持したSeriesを定義
odd_or_even_labels = foo['a'] % 2
p(foo.groupby(odd_or_even_labels).size())

msg("nuniqueなど、重複関連をgroupbyに適用。groupby だけでなく、DataFrameやSeriesにも使える。")
p(foogb['a'].nunique())
p(foogb['a'].unique())
p(foogb['a'].value_counts())

msg("SQLのGROUP BY 時のHAVING句相当の演算")
s = foogb['a'].nunique()
p(s >= 2)
p(s[s >= 2])
p(s.reset_index().query('a >= 2'))


msg("pandasのオブジェクト_groupbyその1")

p(foogb['fruit'])
p(type(foogb['fruit']))
p(foogb['a'])
foogb_tmp = foogb.apply(lambda x: x)
p(type(foogb_tmp))
p(foogb_tmp)

msg("pandasのオブジェクト_groupbyその2")
chktype = [
    foogb,
    foogb.apply(lambda x: x),
    foogb.agg(['max'])
]

for idx, i in enumerate(chktype):
    print('---------------------------------------------')
    print(idx)
    print(type(i))
    print(i.dtypes)
    print('------------')
    if check_member_simply('columns', i):
        print(i.columns)
    print('------------')
    if check_member_simply('index', i):
        print(i.index)
    print('------------')
    print(i)

print('------確認-----------')
foogb_tmp = foogb.agg(['max']).reset_index()
print(foogb_tmp)
print(foogb_tmp.columns)
print('------確認-----------')

foogb_tmp = foogb.agg(['max'])[('a', 'max')]
print(type(foogb_tmp))
print(foogb_tmp)

foogb_tmp = foogb.agg(['max'])[[('a', 'max'), ('c', 'max')]]
print(type(foogb_tmp))
print(foogb_tmp)


"""
★6★

様々なデータの読み込み。...といってもここではJSONに絞りましたが... 

"""

msg("JSON読み込み1: read_json")
data1 = '{"col1":{"r1":1,"r2":3},"col2":{"r1":[{"v":2}],"r2":[{"v":4}]},      "col3": { "r1":{"w":10},    "r2":{"w":20}   }  }'

df_s = pd.read_json(data1)

p(df_s)

msg("JSON読み込み2: json.loadsによるJSON→ Pythonのdict、これをDataFrameコンストラクタに与える。")
df_x = pd.DataFrame(json.loads(data1))
p(df_x)

data = {'json_col': ['{"name": "soudegesu", "age": "30", "address": {"area": "東京"}}',
                     '{"name": "hogehoge", "age": "10", "address": {"area": "北海道"}}']}

msg("JSON読み込み3: json_normalize。 ")
"""
json_normalizeは俗な理解の仕方だと...
ネストされたJSONを「prop1.prop2.prop3」のような列名にバラしてDataFrameに入れてくれる。
(配列が出てくるまではネストしたJSONを掘り下げてくれる)
"""
pd.set_option('display.max_columns', None)
df_json = json_normalize(data)
p(df_json)
p(df_json.head())

msg("以下、json_normalizeの実験")
data = {"col1": {"r1": 1, "r2": 3}, "col2": {"r1": [{"v": 2}], "r2": [
    {"v": 4}]},      "col3": {"r1": {"w": 10},    "r2": {"w": 20}}}

msg("")
p(json_normalize(data).head())
msg("")
p(json_normalize({"x": {"a": 1, "b": 2}, "y": {"c": 3, "d": 4}}))
msg("")
p(json_normalize({"x": {"a": {"c": 99}, "b": 2}, "y": {"a": 3, "b": 4}}))
msg("")
p(json_normalize({"x": {"a": {"c": 99}, "b": 2}, "y": {"a": 3, "b": 4}}).to_json(
    force_ascii=False, orient='records', lines=True))
msg("")
p(json_normalize({"a": [1, 2, 3], "b": 4}))
msg("")
p(json_normalize([{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"c": 5}]))


"""

ちなみに、JSONから入ったが、基本のDataFrame.read_csvや DataFrame.to_csvといった名前からお察しできる便利な関数も充実している。
ここではご紹介しないですが、ファイル入出力については、たいていのことはできるのでつど確認。

"""

"""
★7★

ここまで、見よう見まねの雰囲気で使って見た例のからくりを確認。
(「type」を確かめるなどして、型を意識すると理解が進むかも。最初に意識した方が良い話で、今更ですが...)
"""
chktype = [1, 'str', (lambda x: x), [1, 2], (1, 2), {
    '1': '2'}, foo, foo['a'], foo[['a', 'b']], foogb]

for i in chktype:
    print(type(i))

msg("pandasのオブジェクト")
chktype = [
    foo.apply(lambda x: x),
    foo.apply(lambda x: x, axis=1),
    foo['a'].apply(lambda x:x),
    foo.apply(lambda x: max(x)),
    foo.apply(lambda x: x['a'], axis=1),
    foo.apply(lambda x: [x['a'], x['b']], axis=1),
    foo.apply(lambda x: pd.Series([x['a'], x['b']]), axis=1),

]


for idx, i in enumerate(chktype):
    print('---------------------------------------------')
    print(idx)
    print(type(i))
    print(i.dtypes)
    print('------------')
    if check_member_simply('columns', i):
        print(i.columns)
    print('------------')
    print(i)


"""
★8★

reset_indexというのがあります。
これは、データを削除した後などにインデックスを振り直すのが主要な目的ですが、文字列ごにょごにょ系のETLとしてPandasを使う場合には、どちらかといえば、groupby後に得られたDataFrameGroupBy、SeriesGroupByを通常のDataFrameやSeriesに変換して、以降の処理を続けるのに使うという用途が多いような気がします。

"""

msg("reset_indexとは???")
p(foo)
msg("")
p(foo.index)
msg("")
p(foo.index.values)
msg("")
p(foo.reset_index())
msg("")
p(foo.reset_index().reset_index())
msg("")
p(foo.reset_index().reset_index()['level_0'])


"""
 Numpy由来の関数のベクトル化(便利だよ)
 俗に言うと、おまじないをつけると、普通の関数を配列を引数にとって結果の配列を戻り値に返せるように拡張できる。
"""
msg("関数のベクトル化")


def a_plus_b(a, b):
    # 単一の値をとる変数を引数にとる関数
    return a + b


msg("おまじない適用前")
p(a_plus_b(1, 3))
# ↓
# 4

# おまじない
v_a_plus_b = np.vectorize(a_plus_b)

msg("おまじない適用後")
# listを引数にとる
p(v_a_plus_b([1, 2, 3], [3, 6, 10]))


# その他、覚えておくと便利な用語や概念など ------------
# 集合関数、ピボット(pivot_table)、カテゴリ、replace...
# メソッドチェインで記述したい → pandas pipe() でググる
# 実はここではあまり触れなかったが、欠損値の扱い(当然便利な関数がそれなりに揃っている)。
# ソートしたり、列名を変えたりといったことも当然可能。
#
# ※私の広くない経験から言うと、エンプラ系のETLツールはその位置づけから、型や項目定義に厳格すぎる。
#  例えば、最初にデータを取り込んだ後に、一括で列名の変更を行うといったこともやりづらい。
# 今回テーマにしているような文字列中心のラフなデータに関するデータを扱うにあたっては、エンプラ系のETLツールが想定しているビジネスロジック・ルールの扱いよりも、メタな文字列処理を全ての列にざっくり適用する... というようなケースが多い....気がする。
# ...ので、その点では、もともとPandasがこれらを意識したものだからかどうかはわからないが、
#
# Pandasと言うよりは、Pythonならではだが、「リスト内表記 Python」でググっておきましょう。

サンプルコードの実行結果

----------------DataFrame取り込み----------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
----------------列方向の項目名----------------
Index(['a', 'b', 'c', 'fruit'], dtype='object')
----------------DataFrameの中身----------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
----------------DataFrameの値をlistのlistで出力----------------
[[1 1 1 'apple']
 [2 2 2 'orange']
 [3 3 3 'lemon']
 [4 4 4 'lemon']]
----------------DataFrameの列へのアクセス----------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
--------------------------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
--------------------------------
   a  c
0  1  1
1  2  2
2  3  3
3  4  4
--------------------------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
----------------applyによる宣言っぽい一括データ操作その1----------------
   a  b  c         fruit
0  2  2  2    appleapple
1  4  4  4  orangeorange
2  6  6  6    lemonlemon
3  8  8  8    lemonlemon
----------------Numpy由来のブロードキャスト----------------
   a  b  c         fruit
0  2  2  2    appleapple
1  4  4  4  orangeorange
2  6  6  6    lemonlemon
3  8  8  8    lemonlemon
----------------applyによる宣言っぽい一括データ操作その2----------------
a             4
b             4
c             4
fruit    orange
dtype: object
----------------loc[行の指定,列の指定]によるアクセス----------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
--------------------------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
--------------------------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
--------------------------------
2
--------------------------------
   a  b
1  2  2
2  3  3
3  4  4
--------------------------------
----------------filterによる列名の正規表現でのアクセス----------------
   c
0  1
1  2
2  3
3  4
----------------queryによる列の値が該当の条件にヒットする行の抽出。つまりselect----------------
   a  b  c   fruit
1  2  2  2  orange
----------------DataFrame['a'] > 1 は、列aの値が1より大きい行の、boolのSeries(行インデックス番号付き)返す----------------
0    False
1     True
2     True
3     True
Name: a, dtype: bool
----------------なので、foo[foo['a'] > 1]は、selectとして機能する----------------
   a  b  c   fruit
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
----------------条件に該当した行のみ、値を追加----------------
補足:Pythonは参照渡しでこれは普通なのかもしれないけど、PandasでETLっぽい視点でデータ操作しているとデータを潰してしまう。
ここでは、DataFrame fooは触りたくないので、foo.copy()で新しいfootmpに代入する。
footmp = foo.copy()
footmp['x'] = 1 
   a  b  c   fruit  x
0  1  1  1   apple  1
1  2  2  2  orange  1
2  3  3  3   lemon  1
3  4  4  4   lemon  1
'a'の値が2より大きい行だけ、'b'の値を10倍して、その値を'only_over2'の列に代入
   a  b  c   fruit  x  only_over2
0  1  1  1   apple  1         NaN
1  2  2  2  orange  1         NaN
2  3  3  3   lemon  1        30.0
3  4  4  4   lemon  1        40.0
----------------複数のDataFrameの結合(SQLのJOIN、....)----------------
SQLのJOINにあたるのはDataFrame.merge
   a  b_l  c_l fruit_l  b_r  c_r fruit_r   ind
0  1    1    1   apple    1    1   apple  both
1  2    2    2  orange    2    2  orange  both
2  3    3    3   lemon    3    3   lemon  both
3  4    4    4   lemon    4    4   lemon  both
----------------PandasはNumPyが親なので(だからだと思うが)、こんな演算もできる----------------
0    2
1    4
2    6
3    8
dtype: int64
--------------------------------
0     1
1     4
2     9
3    16
dtype: int64
----------------代入もできる。Seriesどうしを演算して新たなSeriesを生成してそれを、DataFrameの1列(Series)に代入する----------------
   a  b  c   fruit  a+b
0  1  1  1   apple    2
1  2  2  2  orange    4
2  3  3  3   lemon    6
3  4  4  4   lemon    8
----------------複数列にアクセスするとDataFrameが戻る----------------
   a  a+b
0  1    2
1  2    4
2  3    6
3  4    8
----------------applyのからくり: DataFrame.apply()----------------
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[1 2 3 4]
seriesのindex
RangeIndex(start=0, stop=4, step=1)
seriesをprint
0    1
1    2
2    3
3    4
Name: a, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[1 2 3 4]
seriesのindex
RangeIndex(start=0, stop=4, step=1)
seriesをprint
0    1
1    2
2    3
3    4
Name: b, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[1 2 3 4]
seriesのindex
RangeIndex(start=0, stop=4, step=1)
seriesをprint
0    1
1    2
2    3
3    4
Name: c, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
['apple' 'orange' 'lemon' 'lemon']
seriesのindex
RangeIndex(start=0, stop=4, step=1)
seriesをprint
0     apple
1    orange
2     lemon
3     lemon
Name: fruit, dtype: object
a        10000
b        10000
c        10000
fruit    10000
dtype: int64
----------------applyのからくり: DataFrame.apply(,axis=1)----------------
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[1 1 1 'apple']
seriesのindex
Index(['a', 'b', 'c', 'fruit'], dtype='object')
seriesをprint
a            1
b            1
c            1
fruit    apple
Name: 0, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[2 2 2 'orange']
seriesのindex
Index(['a', 'b', 'c', 'fruit'], dtype='object')
seriesをprint
a             2
b             2
c             2
fruit    orange
Name: 1, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[3 3 3 'lemon']
seriesのindex
Index(['a', 'b', 'c', 'fruit'], dtype='object')
seriesをprint
a            3
b            3
c            3
fruit    lemon
Name: 2, dtype: object
my_funcがapplyよりコールされました
seriesのtype
<class 'pandas.core.series.Series'>
seriesのサイズ
4
seriesのvalues
[4 4 4 'lemon']
seriesのindex
Index(['a', 'b', 'c', 'fruit'], dtype='object')
seriesをprint
a            4
b            4
c            4
fruit    lemon
Name: 3, dtype: object
0    10000
1    10000
2    10000
3    10000
dtype: int64
   a  b  c         fruit
0  2  2  2    appleapple
1  4  4  4  orangeorange
2  6  6  6    lemonlemon
3  8  8  8    lemonlemon
----------------Seriesのイテレーター----------------
__iter___方式
1
2
3
4
--------------------------------
teritems方式
0
1
1
2
2
3
3
4
----------------DataFrameのイテレーター----------------
__iter___方式
<class 'str'>
a
<class 'str'>
b
<class 'str'>
c
<class 'str'>
fruit
--------------------------------
teritems方式
a
0    1
1    2
2    3
3    4
Name: a, dtype: int64
1
b
0    1
1    2
2    3
3    4
Name: b, dtype: int64
1
c
0    1
1    2
2    3
3    4
Name: c, dtype: int64
1
fruit
0     apple
1    orange
2     lemon
3     lemon
Name: fruit, dtype: object
apple
--------------------------------
iterrows方式
0
a            1
b            1
c            1
fruit    apple
Name: 0, dtype: object
1
1
a             2
b             2
c             2
fruit    orange
Name: 1, dtype: object
2
2
a            3
b            3
c            3
fruit    lemon
Name: 2, dtype: object
3
3
a            4
b            4
c            4
fruit    lemon
Name: 3, dtype: object
4
Pandas(Index=0, a=1, b=1, c=1, fruit='apple')
Pandas(Index=1, a=2, b=2, c=2, fruit='orange')
Pandas(Index=2, a=3, b=3, c=3, fruit='lemon')
Pandas(Index=3, a=4, b=4, c=4, fruit='lemon')
----------------dropによる列の削除。ここでは略しているが破壊的に削除するオプション(引数)もある----------------
   a  c   fruit
0  1  1   apple
1  2  2  orange
2  3  3   lemon
3  4  4   lemon
----------------select_dtypesの活用。説明略。----------------
   a  b  c
0  1  1  1
1  2  2  2
2  3  3  3
3  4  4  4
   a  b  c
0  1  1  1
1  2  2  2
2  3  3  3
3  4  4  4
[[1 1 1]
 [2 2 2]
 [3 3 3]
 [4 4 4]]
a    8
b    8
c    8
dtype: int64
a    1,2,3,4
b    1,2,3,4
c    1,2,3,4
dtype: object
<class 'pandas.core.groupby.DataFrameGroupBy'>
--------------------------------
<pandas.core.groupby.SeriesGroupBy object at 0x10dbde400>
--------------------------------
<class 'pandas.core.groupby.SeriesGroupBy'>
----------------groupbyと基本的な使い方。まずは雰囲気をつかむ。ここではfruit列でグループ化----------------
{'apple': Int64Index([0], dtype='int64'), 'lemon': Int64Index([2, 3], dtype='int64'), 'orange': Int64Index([1], dtype='int64')}
--------------------------------
fruit
apple     1
lemon     2
orange    1
dtype: int64
--------------------------------
        a  b  c
fruit          
apple   1  1  1
lemon   7  7  7
orange  2  2  2
--------------------------------
          a    b    c
fruit                
apple   1.0  1.0  1.0
lemon   3.5  3.5  3.5
orange  2.0  2.0  2.0
--------------------------------
          a    b    c
fruit                
apple   1.0  1.0  1.0
lemon   3.5  3.5  3.5
orange  2.0  2.0  2.0
--------------------------------
        a  b  c
fruit          
apple   1  1  1
lemon   3  3  3
orange  2  2  2
--------------------------------
        a  b  c
fruit          
apple   1  1  1
lemon   3  3  3
orange  2  2  2
----------------groupby だんだん分からなくなってくる----------------
          a                     b                     c                 
       mean min max <lambda> mean min max <lambda> mean min max <lambda>
fruit                                                                   
apple   1.0   1   1        0  1.0   1   1        0  1.0   1   1        0
lemon   3.5   3   4        1  3.5   3   4        1  3.5   3   4        1
orange  2.0   2   2        0  2.0   2   2        0  2.0   2   2        0
--------------------------------
          a
fruit      
apple   1.0
lemon   3.5
orange  2.0
--------------------------------
fruit
apple      1
lemon     25
orange     4
dtype: int64
--------------------------------
fruit
apple      apple
lemon      lemon
orange    orange
dtype: object
--------------------------------
----------------groupby のイテレーション----------------
--name--
apple
--group--
   a  b  c  fruit
0  1  1  1  apple
--type(group)--
<class 'pandas.core.frame.DataFrame'>
--name--
lemon
--group--
   a  b  c  fruit
2  3  3  3  lemon
3  4  4  4  lemon
--type(group)--
<class 'pandas.core.frame.DataFrame'>
--name--
orange
--group--
   a  b  c   fruit
1  2  2  2  orange
--type(group)--
<class 'pandas.core.frame.DataFrame'>
--------------------------------
apple
[1]
apple:1
lemon
[3, 4]
lemon:3,4
orange
[2]
orange:2
--------------------------------
----------------グループ化の条件を関数で与えることができる----------------
a
0    2
1    2
dtype: int64
----------------nuniqueなど、重複関連をgroupbyに適用。groupby だけでなく、DataFrameやSeriesにも使える。----------------
fruit
apple     1
lemon     2
orange    1
Name: a, dtype: int64
fruit
apple        [1]
lemon     [3, 4]
orange       [2]
Name: a, dtype: object
fruit   a
apple   1    1
lemon   3    1
        4    1
orange  2    1
Name: a, dtype: int64
----------------SQLのGROUP BY 時のHAVING句相当の演算----------------
fruit
apple     False
lemon      True
orange    False
Name: a, dtype: bool
fruit
lemon    2
Name: a, dtype: int64
   fruit  a
1  lemon  2
----------------pandasのオブジェクト_groupbyその1----------------
<pandas.core.groupby.SeriesGroupBy object at 0x10dc2c0b8>
<class 'pandas.core.groupby.SeriesGroupBy'>
<pandas.core.groupby.SeriesGroupBy object at 0x10dc2c0b8>
<class 'pandas.core.frame.DataFrame'>
   a  b  c
0  1  1  1
1  2  2  2
2  3  3  3
3  4  4  4
----------------pandasのオブジェクト_groupbyその2----------------
---------------------------------------------
0
<class 'pandas.core.groupby.DataFrameGroupBy'>
            a      b      c
fruit                      
apple   int64  int64  int64
lemon   int64  int64  int64
orange  int64  int64  int64
------------
------------
------------
<pandas.core.groupby.DataFrameGroupBy object at 0x10db6ac50>
---------------------------------------------
1
<class 'pandas.core.frame.DataFrame'>
a    int64
b    int64
c    int64
dtype: object
------------
Index(['a', 'b', 'c'], dtype='object')
------------
RangeIndex(start=0, stop=4, step=1)
------------
   a  b  c
0  1  1  1
1  2  2  2
2  3  3  3
3  4  4  4
---------------------------------------------
2
<class 'pandas.core.frame.DataFrame'>
a  max    int64
b  max    int64
c  max    int64
dtype: object
------------
MultiIndex(levels=[['a', 'b', 'c'], ['max']],
           labels=[[0, 1, 2], [0, 0, 0]])
------------
Index(['apple', 'lemon', 'orange'], dtype='object', name='fruit')
------------
         a   b   c
       max max max
fruit             
apple    1   1   1
lemon    4   4   4
orange   2   2   2
------確認-----------
    fruit   a   b   c
          max max max
0   apple   1   1   1
1   lemon   4   4   4
2  orange   2   2   2
MultiIndex(levels=[['a', 'b', 'c', 'fruit'], ['max', '']],
           labels=[[3, 0, 1, 2], [1, 0, 0, 0]])
------確認-----------
<class 'pandas.core.series.Series'>
fruit
apple     1
lemon     4
orange    2
Name: (a, max), dtype: int64
<class 'pandas.core.frame.DataFrame'>
         a   c
       max max
fruit         
apple    1   1
lemon    4   4
orange   2   2
----------------JSON読み込み1: read_json----------------
    col1        col2       col3
r1     1  [{'v': 2}]  {'w': 10}
r2     3  [{'v': 4}]  {'w': 20}
----------------JSON読み込み2: json.loadsによるJSON→ Pythonのdict、これをDataFrameコンストラクタに与える。----------------
    col1        col2       col3
r1     1  [{'v': 2}]  {'w': 10}
r2     3  [{'v': 4}]  {'w': 20}
----------------JSON読み込み3: json_normalize。 ----------------
                                            json_col
0  [{"name": "soudegesu", "age": "30", "address":...
                                            json_col
0  [{"name": "soudegesu", "age": "30", "address":...
----------------以下、json_normalizeの実験----------------
--------------------------------
   col1.r1  col1.r2     col2.r1     col2.r2  col3.r1.w  col3.r2.w
0        1        3  [{'v': 2}]  [{'v': 4}]         10         20
--------------------------------
   x.a  x.b  y.c  y.d
0    1    2    3    4
--------------------------------
   x.a.c  x.b  y.a  y.b
0     99    2    3    4
--------------------------------
{"x.a.c":99,"x.b":2,"y.a":3,"y.b":4}
--------------------------------
           a  b
0  [1, 2, 3]  4
--------------------------------
     a    b    c
0  1.0  2.0  NaN
1  3.0  4.0  NaN
2  NaN  NaN  5.0
<class 'int'>
<class 'str'>
<class 'function'>
<class 'list'>
<class 'tuple'>
<class 'dict'>
<class 'pandas.core.frame.DataFrame'>
<class 'pandas.core.series.Series'>
<class 'pandas.core.frame.DataFrame'>
<class 'pandas.core.groupby.DataFrameGroupBy'>
----------------pandasのオブジェクト----------------
---------------------------------------------
0
<class 'pandas.core.frame.DataFrame'>
a         int64
b         int64
c         int64
fruit    object
dtype: object
------------
Index(['a', 'b', 'c', 'fruit'], dtype='object')
------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
---------------------------------------------
1
<class 'pandas.core.frame.DataFrame'>
a         int64
b         int64
c         int64
fruit    object
dtype: object
------------
Index(['a', 'b', 'c', 'fruit'], dtype='object')
------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
---------------------------------------------
2
<class 'pandas.core.series.Series'>
int64
------------
------------
0    1
1    2
2    3
3    4
Name: a, dtype: int64
---------------------------------------------
3
<class 'pandas.core.series.Series'>
object
------------
------------
a             4
b             4
c             4
fruit    orange
dtype: object
---------------------------------------------
4
<class 'pandas.core.series.Series'>
int64
------------
------------
0    1
1    2
2    3
3    4
dtype: int64
---------------------------------------------
5
<class 'pandas.core.series.Series'>
object
------------
------------
0    [1, 1]
1    [2, 2]
2    [3, 3]
3    [4, 4]
dtype: object
---------------------------------------------
6
<class 'pandas.core.frame.DataFrame'>
0    int64
1    int64
dtype: object
------------
RangeIndex(start=0, stop=2, step=1)
------------
   0  1
0  1  1
1  2  2
2  3  3
3  4  4
----------------reset_indexとは???----------------
   a  b  c   fruit
0  1  1  1   apple
1  2  2  2  orange
2  3  3  3   lemon
3  4  4  4   lemon
--------------------------------
RangeIndex(start=0, stop=4, step=1)
--------------------------------
[0 1 2 3]
--------------------------------
   index  a  b  c   fruit
0      0  1  1  1   apple
1      1  2  2  2  orange
2      2  3  3  3   lemon
3      3  4  4  4   lemon
--------------------------------
   level_0  index  a  b  c   fruit
0        0      0  1  1  1   apple
1        1      1  2  2  2  orange
2        2      2  3  3  3   lemon
3        3      3  4  4  4   lemon
--------------------------------
0    0
1    1
2    2
3    3
Name: level_0, dtype: int64
----------------関数のベクトル化----------------
----------------おまじない適用前----------------
4
----------------おまじない適用後----------------
[ 4  8 13]

おしまいです。

*1:ググった範囲では、体系的にまとめられているもの、ガチ勢か、MLなど「その先」の高度な内容が中心でした。

Elasticsearch公式JavaScriptクライアントをブラウザで使ってみる&Vue.js/Vuetifyでなんちゃって検索サービスSPA風をスクラッチ

はじめに

Elasticsearchには、JavaScript版の公式クライアントライブラリがあるのですが、ブラウザでも動作するようなので、それで主に検索中心に少し遊んでみました。 といっても、ここ↓にある例のまんまです。

www.elastic.co

ただし、さすがに上記のものまんまでは記事としてはウリが弱いです。

そこで、Vue.jsとその有名UIフレームワークであるvuetifyを付け焼き刃で付け足してみて、検索サービスっぽい絵面にしてみました。

f:id:azotar:20190305203709p:plain

Elasticsearchのエンドポイントへhttp/httpsが届く圏内にある端末であれば、後述の内容のコピペ+生のHTMLをちょいと修正するだけで、(ほぼ)ブラウザだけで、Elasticsearchに接続して、多少自分でゴリゴリ・ごにょごにょできるサンドボックスができるねという話です。

この記事の隠れ想定シーン

長くなるので詳しくは述べませんが、この記事は気持ち的には次の記事の続きです。

itdepends.hateblo.jp

本格的ツールを持ち込めない謎区画に、HTMLファイルならただのテキストファイルですからってことで正規に持ち込める中で苦しくも頑張ろう...というようなシーンを意識しています。

その割にVue.jsなどを使って少しズル?をしています。

が...これらも含めて生のjsファイルも持ち込めば、本当に端末のデスクトップでHTMLファイルのアイコンをダブルクリックしても動くものになっています。 *1

ライブラリ等について

ElasticsearchのJavaScript版の公式クライアントについては、様々なAPIが提供されていますが、今回は、クエリDSLがそのまま使えるsearch apiのみを使いました。*2

www.elastic.co

Vue.jsについてはここでは説明しませんが、見てのとおりCDNから読み込みすることとしました。つまりビルド環境などは特に用いません。

vuetifyは、Vue.jsをプラットフォームとしたUIフレームワークです。雑にいうと、マテリアルデザイン風のパーツをBootstrapライクなグリッドシステム型など指定でCSSを用意せずに、それっぽく組み合わせてUIが作成できます。

なお、vuetifyも、CDNから読み込みしています。なのでひとまずエコシステム等を意識せずにひとまず動かしてみることができます。

また、ここでは深くふれませんが、代わりにvuetifyの公式リファレンスへリンクしておきます。 Getting startedのページを流し読みして、その後、画面左のメニューのところで、UI Componentsのところをつまみ食いして覚えていくのが近道だと思いました。

vuetifyjs.com

予備知識: 公式クライアントを用いた最小限の動作方法の例

この手のものは、公式ドキュメントが充実しててもしてなくても、それが動いてみるまではなかなかに厄介です。

試行錯誤の結果(?)、これがおおよそ最小限の検索の例かな...というところを予備知識として示しておきます。

Elasticsearchブラウザ版 JavaScriptクライアントライブラリのミニマム使用例

下記をhoge.htmlなどにコピペ保存して、インデックスや接続先を所定のものに書き換えてください。

ブラウザを起動して、consoleを開くと、検索結果のJSONが返ってくるのが分かると思います。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
    ここには何もでません。<br>
    ブラウザのConsoleをごらんください。
</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    const client = new $.es.Client(ES_HOST)
    async function search() {
        let rslt = await client.search(
            {
                index: INDEX_NAME,
                body: {
                    query: { match_all: {} }
                }
            }
        )
        console.info(JSON.stringify(rslt))
    }

    search()
</script>
</html>

今回用いるElasticsearchのインデックスの塩梅

さて、本題に戻ります。

インデックス名は、es_indexとしました。

また、ここでは簡単のため、localhost:9200でElasticsearchのエンドポイントが待っていることとしました。

なお、インデックスデータですが「livedoorグルメDataSets」 http://blog.livedoor.jp/techblog/archives/65836960.html

を次のイメージのJSONファイルに変換して、これをバルクロードしたものを元ネタとしたという体で記載しています。

{"index": { "_id":999 }}
{"id":105,"name":"お店の名前","alphabet":null,"name_kana":"おみせのなまえのかな","pref_id":13,"area_id":1,"category_id1":509,"category_id2":0,"category_id3":0,"category_id4":0,"category_id5":0,"zip":"111-9999","address":"中央区金座1-2-3","north_latitude":"99.40.09.271","east_longitude":"199.46.66.911","description":" 金座駅A13出口徒歩1分。","special_count":1,"menu_count":3,"fan_count":0,"name_area":"銀座、新橋、有楽町","cats":["地酒","","","",""],"name_pref":"東京都"}

参考:livedoorデータセットを取り込むための手順の例

なお、elasticsearch.jsはCDNが分からなかったので、ローカルに置いています。

Vue.jsとvuetifyを使って検索サービスの雰囲気を出してみる(本記事の本題)

ということで、SPAならぬ、ほぼシングルファイル1枚資材による検索サービス風、PoC、トラブル調査のツールです。

なお、Elasticsearchに繋げなくても、ひとまず、インターネットに繋がっていれば、それっぽい検索窓は表示されると思います。

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.min.css" rel="stylesheet">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<style>
    html {
        overflow-y: scroll;
    }
</style>
<body>
    <div id="app" app-data="true">
        <v-container>
            <v-card>
                <v-flex>
                    <v-card color="grey lighten-4" flat>
                        <v-flex px-4 py-4>
                            <v-layout column>

                                <v-layout row>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw1" label="Find" solo clearable autocomplete="on"
                                            list="sugList" @input="sbox_suggest(1)">
                                        </v-text-field>
                                        <datalist id="sugList">
                                            <option v-for="(item, index) in sugList" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw2" label="Near" solo clearable autocompleate="on"
                                            list="sugList2" @input="sbox_suggest(2)"></v-text-field>
                                        <datalist id="sugList2">
                                            <option v-for="(item, index) in sugList2" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md2>
                                        <v-btn color="blue" v-on:click="es_search(0)">
                                            <v-icon color="white">search</v-icon><span class="white--text">Search</span>
                                        </v-btn>
                                    </v-flex>
                                </v-layout>

                                <v-layout row>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Restaurants">
                                        </v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Home Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Auto Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="More..."></v-select>
                                    </v-flex>
                                </v-layout>

                            </v-layout>
                            <v-layout>
                                <v-flex>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot genre
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot tag
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered filter
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>recomend...
                                    </v-btn>
                                </v-flex>
                            </v-layout>

                        </v-flex>
                    </v-card>
                </v-flex>


                <v-flex v-if="total" px-4 py-4>
                    <v-card dark tile flat color="grey lighten-4">
                        <v-layout row>
                            <v-flex md10 grow px-4 py-1>
                                <v-card-text class="black--text">
                                    {{cond}} {{total}}件ヒットしました: 現在{{ctrl.current}}件表示中
                                </v-card-text>
                            </v-flex>
                            <v-flex md1 shrink>
                                <v-menu offset-y open-on-hover>
                                    <v-btn slot="activator" flat color="black">
                                        Sort by<v-icon>expand_more</v-icon>
                                    </v-btn>
                                    <v-list>
                                        <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                            <v-list-tile-title>選択肢</v-list-tile-title>
                                        </v-list-tile>
                                    </v-list>
                                </v-menu>
                            </v-flex>
                        </v-layout>
                    </v-card>
                    <v-flex px-4>
                        <v-breadcrumbs :items="b_items" divider=">"></v-breadcrumbs>
                    </v-flex>

                    <v-flex px-4 py-2>
                        <v-layout column>
                            <v-flex>
                                <v-dialog v-model="push_me_dialog" width="500">

                                    <v-btn color="white" small slot="activator">
                                        <v-icon>flag</v-icon><span>Push Me!</span>
                                    </v-btn>


                                    <v-card>
                                        <v-card-title class="headline grey lighten-2" primary-title>
                                            探すエリアを変更
                                        </v-card-title>

                                        <v-card-text>
                                            下記に選択肢を表示し、検索BOX2の条件を選択し直します。レストラン検索なら住所やエリア、駅などの一覧を表示。
                                        </v-card-text>

                                        <v-divider></v-divider>

                                        <v-card-text>
                                            住所一覧/エリア一覧/駅一覧...
                                        </v-card-text>
                                        <v-card-actions>
                                            <v-spacer></v-spacer>
                                            <v-btn color="primary" flat @click="push_me_dialog = false">
                                                <v-icon>search</v-icon> 再検索
                                            </v-btn>
                                        </v-card-actions>
                                    </v-card>
                                </v-dialog>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>

                                <template v-for="n in 2">
                                    <v-menu offset-y open-on-hover>
                                        <v-btn slot="activator" small color="white">
                                            <v-icon>flag</v-icon>
                                            SELECT
                                        </v-btn>
                                        <v-list>
                                            <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                                <v-list-tile-title>単一絞り込み</v-list-tile-title>
                                            </v-list-tile>
                                        </v-list>
                                    </v-menu>
                                </template>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>
                            </v-flex>
                            <v-flex>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 4">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <v-btn color="white" small>
                                        <v-icon>schedule</v-icon><span>OpenNow</span>
                                    </v-btn>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 3">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 2">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                            </v-flex>
                            <v-flex>
                                <template v-for="(v,k) in aggs">
                                    <v-btn small outline round v-on:click="es_search(0,k,v2.key)" v-for="(v2,i2) in v.buckets"
                                        v-bind:key="v2.key">
                                        {{v2.key}}
                                    </v-btn>
                                </template>
                            </v-flex>
                        </v-layout>
                    </v-flex>




                    <hr>
                    <v-list two-line>
                        <template v-for="(item, index) in hits">
                            <v-subheader v-if="item.header" :key="item.header">
                                {{ item.header }}
                            </v-subheader>

                            <v-divider v-else-if="item.divider" :key="index" :inset="item.inset"></v-divider>

                            <v-list-tile v-else :key="item._source._id" avatar @click=""> 

                                <v-list-tile-avatar>
                                    <v-icon>card_giftcard</v-icon>
                                </v-list-tile-avatar>
                                <v-list-tile-content>
                                    <v-list-tile-title>
                                        <span>{{index + 1}}</span>.
                                          {{item._source.name}}({{item._source.name_kana}})
                                        <!-- //CONFIG -->
                                    </v-list-tile-title>
                                    <v-list-tile-sub-title v-if="item._source.cats && item._source.location">
                                        レコードID:{{item._id}} / {{item._source.address}}
                                        /カテゴリ:{{item._source.cats.join(',')}}/緯度経度:{{item._source.location.join(',')}}/スコア:{{item._score}}
                                    </v-list-tile-sub-title>
                                </v-list-tile-content>
                            </v-list-tile>
                        </template>
                        <span id="rslt_jump_anchor"></span>

                        <v-list-tile>
                            <v-list-tile-content>
                                <v-layout v-show="ctrl.current != total">
                                    <v-fab-transition>
                                        <v-btn color="grey lighten-1 " dark v-on:click="fetch_by_pushed" href="#rslt_jump_anchor">
                                            <v-icon>flash_on</v-icon>次のページを読み込み
                                        </v-btn>
                                    </v-fab-transition>
                                </v-layout>
                            </v-list-tile-content>
                        </v-list-tile>

                    </v-list>

                    <v-layout column style="position: fixed;  bottom: 0px; right: 50px; z-index: 999; ">
                        <v-fab-transition>
                            <v-btn v-show="current_scroll_position > 800 && ctrl.expand_less.valid" color="red lighten-1"
                                dark fab href="#app">
                                <v-icon>expand_less</v-icon>
                            </v-btn>
                        </v-fab-transition>
                    </v-layout>


                </v-flex>

                <div v-show="total==0">
                    検索結果が0件でした。以下の...
                    <v-layout column>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>もしかして(正確な用語に補正、ピボット、検索範囲を拡大...)</v-btn>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>関連検索ワード(履歴、ピボット、検索範囲を拡大...)</v-btn>
                    </v-layout>
                </div>

            </v-card>
        </v-container>

    </div>

</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>

    //CONFIG
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    class OrganicSearchQueryBuilder {
        constructor() {
            //CONFIG
            this.SIZE = 10
            this.KWBOX1_FIELDS = ["name"]
            this.KWBOX2_FIELDS = ["address"]
            this.AGGS_FIELDS = ["cats"]
            this.AGGS = {}
            for (let v of this.AGGS_FIELDS) {
                this.AGGS[v] = { terms: { field: v } }
            }
            this.postFilterWordsSet = new Set()
        }
        getMatchDSL(kw, fields) {
            return { multi_match: { query: kw, fields: fields, operator: "and" } }
        }
        getAggsDSL(from, pfw) {
            if (from == 0 || from === undefined) {
                this.postFilterWordsSet = new Set()
            }
            if (pfw !== undefined) {
                this.postFilterWordsSet.add(pfw)
            }
            if (this.postFilterWordsSet.size == 0) {
                return { bool: { must: [{ match_all: {} }] } }
            }
            let postFilter = { bool: { must: [] } }
            for (let i of this.postFilterWordsSet.values()) {
                postFilter.bool.must.push({ match: { [this.AGGS_FIELDS[0]]: i } }) //CONFIG
            }
            return postFilter
        }
        get2BoxQuery(kw1, kw2, from, pfw) {
            let b = {
                size: this.SIZE, from: from,
                query: {
                    bool: {
                        must: [
                            (kw1 === undefined || kw1 == null || kw1 == '') ? { match_all: {} } : this.getMatchDSL(kw1, this.KWBOX1_FIELDS),
                            (kw2 === undefined || kw2 == null || kw2 == '') ? { match_all: {} } : this.getMatchDSL(kw2, this.KWBOX2_FIELDS)
                        ]
                    }
                },
                post_filter: this.getAggsDSL(from, pfw),
                aggs: this.AGGS
            }
            // console.log(JSON.stringify(b))
            return { index: INDEX_NAME, body: b }
        }
        getFilterWordsString() {
            return "  " + Array.from(this.postFilterWordsSet).join(",")
        }
    }

    const client = new $.es.Client(ES_HOST)
    let QB = new OrganicSearchQueryBuilder()

    var vm = new Vue({
        el: '#app',
        data: () => ({
            s_items: ['Foo', 'Bar', 'Fizz', 'Buzz'],
            sbox: {
                kw1: "",
                kw2: ""
            },
            cond: "",
            total: null,
            hits: null,
            aggs: null,
            filter: null,
            ctrl: {
                current: 0,
                loading: false,
                expand_less: {
                    valid: true
                }
            },
            sugList: [],
            sugList2: [],
            sugLock: false,
            current_scroll_position: 0,
            toggle_multiple: [],
            b_items: [
                {
                    text: 'トップ',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず1',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず2',
                    disabled: false,
                    href: '#'
                },
                {
                    text: '条件1, 条件2',
                    disabled: true,
                    href: 'breadcrumbs_link_2'
                }
            ],
            push_me_dialog: false
        })
        ,
        mounted: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)

            document.onscroll = (e) => {
                this.current_scroll_position = document.documentElement.scrollTop || window.scrollY // document.body.scrollTop ||
            }
        },
        destroyed: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)
        },
        methods: {
            es_search: async function (from, aggsField, aggsValue) {
                if (from > 0 && (this.total != null && this.total == this.ctrl.current)) {
                    return true
                }
                if (this.ctrl.loading == true) { // ざっくり排他制御 -- ここから
                    return true;
                }
                this.ctrl.loading = true
                {
                    let rslt = await client.search(QB.get2BoxQuery(this.sbox.kw1, this.sbox.kw2, from, aggsValue))
                    if (from == 0) {
                        this.total = rslt.hits.total
                        this.filter = QB.getFilterWordsString()
                        this.hits = rslt.hits.hits
                        this.aggs = rslt.aggregations
                        this.ctrl.current = Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    } else {
                        this.hits = this.hits.concat(rslt.hits.hits) // 無限スクロールを考慮して後ろに結合する
                        this.ctrl.current += Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    }
                }
                this.cond = this.sbox.kw1 + " " + this.sbox.kw2 + " " + this.filter
                this.ctrl.loading = false; // ざっくり排他制御 -- ここまで
            },
            fetch_by_pushed: function () {
                let next = this.ctrl.current
                this.es_search(next)
            },
            fetch_by_scrolled: function (e) {
                let docHeight = document.documentElement.scrollHeight // 参考: window.innerHeight 現在表示中のウィンドウのサイズ
                // console.log([this.current_scroll_position, docHeight * 0.25])
                if (this.current_scroll_position > docHeight * 0.25) { // スクロールのカーソルがドキュメントの90%あたり(この計算方法だと、閾値 0.25ぐらい)まで来たらフェッチする。
                    let next = this.ctrl.current
                    this.es_search(next)
                } else {
                    //無視する
                }
            },
            sbox_suggest1: function (FIELD, timeout) {
                let kwbox = this.sbox.kw1
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest2: function (FIELD, timeout) {
                let kwbox = this.sbox.kw2
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList2 = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest: function (box_id) {
                if (box_id == 1) {
                    this.sbox_suggest1("name", 300) //CONFIG
                } else {
                    this.sbox_suggest2("address", 300) //CONFIG
                }
            }
        }
    })

</script>
</html>

ここで、条件に従ったインデックスが用意してあれば、次の部分は動くようにしかけてあります。

  1. フリー入力の検索BOX(2BOXのAND検索)とお店名と住所のオートコンプリート
  2. 検索結果
  3. 検索時に表示される角丸のお店のカテゴリ分類による絞り込み
  4. おまけで、無限スクロール風のギミック
  5. Elasticsearchのクエリ設定と上記などの検索結果の編集部分は、「CONFIG」というコメント文をつけているので、その周辺をそれらしく編集して、接続先のインデックスを   変更したりしてみてください。私のスキル(弱いエンジニアリング)と今回のあえて薄い作りにしたいという

一方、話のついでにvuetifyを使ってみたくて、とりあえずコンポーネントを配置しただけのものもあります。...ので上記以外はハリボテで動かないパーツもあります。ご了承ください。

まとめ

冒頭で設定した(言い訳した?)とおり、閉区画などにありがちなシーンを想定して、開発環境らしきものがなくても、頑張ればサクッとファイルの内容を変更しやすいように、1プログラム資材としてみました。

が試しにやってみたのですが、ハイライトのないWindowsメモ帳エディタなどで開いたらそれだけで死にそうでした。が、その気になればなんとかなると思います。

また、見れば分かると思いますが、Vue.jsもvuetifyの利用の仕方や、同期系の処理や排他についてはツールとしての割り切りになっているところがあります。

例えば、オートコンプリートも本来はこの例だとwatchの方がスジが良いと思いますが、設定よりも規約よりもハードコーディングとしましたので、そんな人はいないと思いますが、動作確認や簡易なツール以上のものを用意する際には、あるべき姿のコーディングスタイルを別途ご確認ください。

付録1: livedoorレストランデータセットについて

上記でlivedoorレストランデータセットを使った...としました。

参考までに、Elasticsearchさえ入っていれば、というところになりますが、このデータセットを取り込む例を示します。ここでは、こういうフォーマットであれば、こうやって取り込む方法があるという例で示しますので、データそのものの引用は控えています。

行間を読んでいただく必要はありますが、ご参考になればということで以下ご覧ください。

データの変換〜インポート

元ネタはタイトル付きCSVですので、これをJSONファイルにしてやります。 コード値で確保されているカテゴリやエリアデータをそれらのマスタCSVと結合してやります。

次のpythonスクリプトを保存してください。

import pandas as pd
import json

#件数を絞り込む場合
res = pd.read_csv('restaurants.csv',skiprows=lambda x: x >1000)
#全件取り込む場合
#res = pd.read_csv('restaurants.csv')

#都道県名結合
pref = pd.read_csv('prefs.csv')
res = pd.merge(res,pref,left_on=["pref_id"], right_on=["id"],how='left',indicator='rp',suffixes=['','_pref'])

#エリア名結合
area = pd.read_csv('areas.csv')
res = pd.merge(res,area,left_on=["area_id"], right_on=["id"],how='left',indicator='ra',suffixes=['','_area'])

#カテゴリ結合〜catsフィールドの派生
cat = pd.read_csv('categories.csv')
res = pd.merge(res,cat,left_on=["category_id1"], right_on=["id"],how='left',indicator='rc',suffixes=['','_cat1'])
res = pd.merge(res,cat,left_on=["category_id2"], right_on=["id"],how='left',indicator='rcc',suffixes=['','_cat2'])
res = pd.merge(res,cat,left_on=["category_id3"], right_on=["id"],how='left',indicator='rccc',suffixes=['','_cat3'])
res = pd.merge(res,cat,left_on=["category_id4"], right_on=["id"],how='left',indicator='rcccc',suffixes=['','_cat4'])
res = pd.merge(res,cat,left_on=["category_id5"], right_on=["id"],how='left',indicator='rccccc',suffixes=['','_cat5'])

df = res

df.name_cat1 = df.name_cat1.fillna('')
df.name_cat2 = df.name_cat2.fillna('')
df.name_cat3 = df.name_cat3.fillna('')
df.name_cat4 = df.name_cat4.fillna('')
df.name_cat5 = df.name_cat5.fillna('')
catcols = 'name_cat1,name_cat2,name_cat3,name_cat4,name_cat5'.split(',')
df = df.assign(cats = df[catcols].values.tolist())

df.north_latitude = df.north_latitude.fillna('')
df.east_longitude = df.east_longitude.fillna('')

#その場しのぎのadhocな緯度経度形式の変換
def funny_dms_to_deg(dms_str):
    if dms_str == '':
        return None
    else:
        dms_str_arr = [ s for s in str(dms_str).split('.') ]
        deg = dms_str_arr[0] + '.' +  dms_str_arr[1] + dms_str_arr[2] + dms_str_arr[3]
        if deg == None:
            return None
        return float(aaa)
    
dtd = funny_dms_to_deg
    
df = df.assign(lon = df['east_longitude'].apply(dtd))
df = df.assign(lat = df['north_latitude'].apply(dtd))
df = df.assign(location =  df[['lon','lat']].values.tolist())

#バルクロード用のjsonldファイルの出力

columns = 'id,name,alphabet,name_kana,pref_id,area_id,category_id1,category_id2,category_id3,category_id4,category_id5,zip,address,north_latitude,east_longitude,description,special_count,menu_count,fan_count,access_count,created_on,modified_on,closed,name_area,cats,name_pref,location'.split(',')
df = pd.DataFrame(df,columns=columns )

df_lines = df.to_json(force_ascii=False, orient='records', lines=True)

for i in iter(df_lines.split("\n")):
    v_json = json.loads(i)
    print('{"index": { "_id" : ' +  str(v_json['id']) + '}}' )
    print(i)


なお、pandasを使っているので、pipなどでpandasをインストールしておいてください。

上記のpythonスクリプト(ld.py)とします。

ld.pyをレストランデータセットのダウンロード〜unzip先のディレクトリ(restaurants.csvなどが存在するディレクトリ)に置いて

python3 ld.py > output.jsonld.in

などとして、output.jsonld.inファイルを出力します。

さらに、curlなどでバルクロードします。

curl -H "Content-type: application/x-ndjson" -X POST localhost:9200/es_index/doc/bulk?refresh --data-binary @output.jsonld.in

さらに補足:mappingの設定

説明が前後しましたが、mappingは雑にdynamic mappingで次の設定としました。

PUT es_index
{
  "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" ]  }
      },
      "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_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}


付録2:速習vuetifyでの項目レイアウトについて

vuetifyの各コンポーネントはなんとなく配置しているうちにわかるのですが、bootstrapライクなグリッドシステムはなれない人にはちょいとわかりにくいかもしれません。というか私は良く分かっていません。

が、次の公式リンクにまとまっているのと、

Layout grid system — Vuetify.js vuetifyjs.com

これをみると、つまるところ、簡単なレイアウトであれば次のパターンで良いのではないかと思いましたので、骨組みとしてご紹介しておきます。

<v-container>
    これで、ページ全体の大枠を縛る。

    <v-layout row>
        囲んだものの中にあるコンポーネントをできるだけ横方向に詰め込むレイアウトとする
        <v-flex md5>
            5単位の幅確保
            <foo>
                表示したい項目A
            </foo>
        </v-flex>

        <v-flex md7>
            7単位の幅確保
            <foo>
                表示したい項目B
            </foo>
        </v-flex>

    </v-layout>

</v-container>

付録3: gistに貼りました

*1:が、モダンブラウザを想定しているので、あくまでシチュエーションコントということで、ご了承くださいまし。

*2:jquery版です。