mattintosh note

技術書典に出たい人生だった…

Elasticsearch で電子書籍ランキングを作ってみた Vol.2

前回の続き。

なんだか時間が経つうちにどんどん収集対象が増えてしまった。

ebookman.ga

新しく Table や Chart.js でグラフを追加してみたが、こういったものをボタンポチポチで簡単に出力できる Kibana や Grafana は本当に便利なのだなと感じた。

f:id:mattintosh4:20190225180358p:plain
Amazon

今日は Elasticsearch のデータを JavaScript でグラフにしたときのことをメモっておく。

Kibana でグラフを作ってみる

まずは Kibana で作りたいグラフのイメージを固める。

グラフを作成するときに X-Axis(Date Histogram)Split Series のどちらを前にするかで結果が変わってくる。

Split Series を優先した場合

最初にアイテムを分割し、それらを時系列で並べる。最初に抽出されたアイテムがその時間に通った順位になるので空きが出来ることがあるが、アイテムごとの最初から最後まで状態を追うことが出来る。アイテム数は「分割数」になる。

f:id:mattintosh4:20190225184408p:plain
Kibana - Split Series を優先した場合

X-Axis を優先した場合

ある時間帯のなかで分割をするので順位に空きが出来ることは無いが、分割の範囲外に出てしまったものは線が切れてしまうことがある。時間帯ごとに分割をするのでアイテム数は「分割数 + α」になることがある。

f:id:mattintosh4:20190225184312p:plain
Elasticsearch / Kibana

Elasticsearch のクエリを書いてみる

Split Series を優先した場合のクエリを書いてみるが、慣れないうちは少し大変かもしれない。

例えばこんな感じでデータが入っているとする。

{
  "@timestamp": "2019-02-25T00:00:00+09:00",
  "book": {
    "name": "五等分の花嫁",
    "rank": 1
  }
}
{
  "@timestamp": "2019-02-25T01:00:00+09:00",
  "book": {
    "name": "五等分の花嫁",
    "rank": 2
  }
}

今回は書籍名 book.name を基点に順位の推移を出していく。

まず、terms で書籍名 book.name を抽出して、それを date_histogram@timestamp ごとに分割する。更にそこから minbook.rank の値を取得する。取得したいフィールドを同じレベルで拾うのか、ネストした aggs 以下で拾うのかによって意味が変わるのだが最初のうちはなかなか慣れない。

stats というのは数値型のフィールドに対して使えるもので、minavgmaxsumcount を同時に取得することができる。これを上位の terms から参照させることでソートキーとして使えるようになる。

Terms … ❶
|
+-- Stats … ❷
|
+-- Date Histogram … ❸
    |
    +-- Min … ❹

データの取得と整形は Python でやっているので Python 用のコードをそのまま載せておくが、JSON もだいたい同じ。ポイントとしては terms などの集計の種類と更にネストする場合の aggs は同じレベルであること。今回は book.name を基点にしているが、基点は複数指定することもできるので1回のクエリで複数の異なる集計結果を得ることも出来るはず。

query = {
    'from': 0,
    'size': 0,
    'query': {
        'query_string': {
            'query': '抽出条件',
        }
    },
    'aggs': {
        '名前❶': {
            'terms': {
                'field': 'book.name.keyword',
                'order': [
                    { '名前❷.min': 'asc' },
                    { '名前❷.avg': 'asc' },
                    { '名前❷.max': 'asc' },
                ],
                'size': 25,
            },
            'aggs': {
                '名前❷': {
                    'stats': {
                        'field': 'book.rank',
                    }
                },
                '名前❸': {
                    'date_histogram': {
                        'field': '@timestamp',
                        'interval': 'hour',
                    },
                    'aggs': {
                        '名前❹': {
                            'min': {
                                'field': 'book.rank',
                            }
                        }
                    }
                }
            }
        }
    }
}

抽出結果

{
  "took" : 82,
  "timed_out" : false,
  "_shards" : {
    "total" : 6,
    "successful" : 6,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 200,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "data" : {
      "doc_count_error_upper_bound" : -1,
      "sum_other_doc_count" : 196,
      "buckets" : [
        {
          "key" : "薬屋のひとりごと 4巻 (デジタル版ビッグガンガンコミックス)",
          "doc_count" : 2,
          "histogram" : {
            "buckets" : [
              {
                "key_as_string" : "2019-02-25T09:00:00.000Z",
                "key" : 1551085200000,
                "doc_count" : 1,
                "rank" : {
                  "value" : 1.0
                }
              },
              {
                "key_as_string" : "2019-02-25T10:00:00.000Z",
                "key" : 1551088800000,
                "doc_count" : 1,
                "rank" : {
                  "value" : 1.0
                }
              }
            ]
          },
          "rank" : {
            "count" : 2,
            "min" : 1.0,
            "max" : 1.0,
            "avg" : 1.0,
            "sum" : 2.0
          }
        },
        {
          "key" : "たとえばラストダンジョン前の村の少年が序盤の街で暮らすような物語 3巻 (デジタル版ガンガンコミックスONLINE)",
          "doc_count" : 2,
          "histogram" : {
            "buckets" : [
              {
                "key_as_string" : "2019-02-25T09:00:00.000Z",
                "key" : 1551085200000,
                "doc_count" : 1,
                "rank" : {
                  "value" : 2.0
                }
              },
              {
                "key_as_string" : "2019-02-25T10:00:00.000Z",
                "key" : 1551088800000,
                "doc_count" : 1,
                "rank" : {
                  "value" : 2.0
                }
              }
            ]
          },
          "rank" : {
            "count" : 2,
            "min" : 2.0,
            "max" : 2.0,
            "avg" : 2.0,
            "sum" : 4.0
          }
        }
      ]
    }
  }
}

Chart.js 用に整形する

抽出したデータを今度は Chart.js 用に整形していく。Chart.js の書式は公式のサンプルでは下記のようになっている。

Chart.js Example

var ctx = document.getElementById('myChart').getContext('2d');
var chart = new Chart(ctx, {
    // The type of chart we want to create
    type: 'line',

    // The data for our dataset
    data: {
        labels: ["January", "February", "March", "April", "May", "June", "July"],
        datasets: [{
            label: "My First dataset",
            backgroundColor: 'rgb(255, 99, 132)',
            borderColor: 'rgb(255, 99, 132)',
            data: [0, 10, 5, 2, 20, 30, 45],
        }]
    },

    // Configuration options go here
    options: {}
});

datasets の指定方法は値を配列で与える方法と xy で指定する方法があるが、ポイントが決まっているので xy の方を採用する。

Chart.js Example

https://www.chartjs.org/docs/latest/charts/line.html#point

data: [{
        x: 10,
        y: 20
    }, {
        x: 15,
        y: 10
    }]

Elasticsearch で取り出したデータをそのまま Python で整形する。Date Histogram にはタイムスタンプが格納された keyタイムゾーン情報付きの key_as_string があり、タイムスタンプはミリ秒まで入っているので 1000 で割る。クエリの段階で date_histogram.formatkey_as_string の書式を指定しておくのもいいかもしれない。詳しくは https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html を参照。線色は特に思いつかなかったのでランダムにする。

response = es.search(index=index, body=query)
labels   = []
datasets = []
for top_bucket in response['aggregations']['data']['buckets']:
    d = {
        'label': top_bucket['key'],
        'data': [
            {
                'x': datetime.fromtimestamp(x['key']/1000).strftime('%Y-%m-%d %H:%M'),
                'y': x['rank']['value'],
            } for x in top_bucket['histogram']['buckets']
        ],
        'borderColor': 'rgba({}, {}, {}, 0.8)'.format(*[int(uniform(128, 255)) for i in range(3)]),
        'borderWidth': 2,
        'fill': False,
    }
    labels += [x['x'] for x in d['data']]
    datasets.append(d)
print(json.dumps({
    'labels': sorted(list(set(labels))),
    'datasets': datasets,
}, ensure_ascii=False)

整形後はこんな感じになる。labels とか labelとか、慣れないうちは困惑するが、labels は横軸の名称で datasets の中の label は凡例。

{
  "datasets": [
    {
      "borderColor": "rgba(241, 233, 170, 0.8)",
      "borderWidth": 2,
      "data": [
        {
          "x": "2019-02-25 21:00",
          "y": 2
        },
        {
          "x": "2019-02-25 22:00",
          "y": 2
        }
      ],
      "fill": false,
      "label": "たとえばラストダンジョン前の村の少年が序盤の街で暮らすような物語 3巻 (デジタル版ガンガンコミックスONLINE)"
    }
  ],
  "labels": [
    "2019-02-25 21:00",
    "2019-02-25 22:00"
  ]
}

Bucket Aggregation 難しい…🤔

www.elastic.co