ElasticsearchのSignigicant Terms AggregationのCustom Scoreによる特徴語抽出

## 概要

株式会社RevCommにて音声認識と全文検索を担当するk_ishiというものです。
この記事は [2020 年の RevComm アドベントカレンダー](https://qiita.com/advent-calendar/2020/revcomm) 21 日目の記事です。 20 日目は @rhoboro さんの「[たった1行から始めるPythonのAST(抽象構文木)入門](https://qiita.com/rhoboro/items/5bbaa3fa659ed7454d56)」でした。

ElasticsearchにはSignificant Terms Aggregationという、テキストフィールドに含まれる特徴語を抽出する機能が搭載されています。Significant Terms Aggregationは、デフォルトではJLH Scoreという指標で特徴語の抽出を行うのですが、どうもPainlessスクリプトを利用して記述するCustom Scoreによる特徴語抽出も可能となっているようです。このCustom Score機能のことがうっすらと気になっていたのですが、どうもネット上に情報が少ないような・・・と思ったので、本稿ではCustom Score機能について調査してまとめてみたいと思います。

## 特徴語とは
特徴語とは、ある属性をもつテキストに偏って出現する単語のことで、Twitterのトレンドのキーワードのようなものをイメージしていただくとわかりやすいと思います。
例えば、2020年12月のテキストには「クリスマス」や「アドベントカレンダー」などの単語が偏って出現するので、これらの単語は2020年12月のテキストの特徴語であると言えます。
ElasticsearchのSignificant Terms Aggregationでは、デフォルトでJLH Scoreというスコアの高い単語を特徴語として抽出します。
JLH Scoreは、下記の通り、指定範囲のドキュメントと全体のドキュメントでの、単語の出現割合を比較してスコアを算出するものです。

```
JLH = 絶対割合変化 × 相対割合変化
絶対割合変化 = 指定範囲のドキュメントでの出現割合 - 全体のドキュメントでの出現割合
相対割合変化 = 指定範囲のドキュメントでの出現割合 ÷ 全体のドキュメントでの出現割合
```

基本的には、全体であまり出現しなかった単語が、指定範囲で多めに出現していれば、その単語のスコアが高くなり、特徴語として抽出されます。
 
## 利用ソフトウェアなど

| ソフトウェア | バージョン | URL |
|:---------|:--------|:----|
| Elasticsearch| 7.10.1|https://www.elastic.co/jp/downloads/elasticsearch |
| Python Elasticsearch Client| 7.10.1 |https://elasticsearch-py.readthedocs.io/en/7.10.0/|
| Citation-Network | v1 | https://cn.aminer.org/citation | 

## データセットの準備

まずは、特徴語抽出の対象とするデータセットをElasticsearchに入れて準備します。
今回はデータセットとして、 https://cn.aminer.org/citation のCitation-Network v1を使います。
まず、Elasticsearchをダウンロードし、ローカルで立ち上げてください。
そして、下記のコマンドでindexを作ります。
Citation-Network v1は国際会議論文のタイトル、著者、国際会議名、開催年、アブストラクト、参考文献の情報が入っているデータセットです。
今回はアブストラクトから特徴語を抽出したいので、 `abstract` の `fielddata` を `true` にしています。
(Significant Terms Aggregationによる特徴語抽出は、fielddata:trueのフィールドでしか使えません)

```bash

curl -H "Content-Type: application/json" -XPUT 'http://localhost:9200/paper' -d '{
    "mappings": {
        "properties": {  
            "title": {
                "type": "text"
            },
            "authors": {
                "type": "text"
            },
            "venue": {
                "type": "text"
            },
            "abstract": {
                "type": "text",
                "fielddata": true
            },
            "year": {
                "type": "text"
            },
            "references": {
                "type": "text"
            }           
        }
    }
}'
```

次に、Citation-Network v1のデータをElasticsearchにインデクシングします。
今回は、下記のスクリプトで1万件だけインデクシングしました。
https://gist.github.com/ken57/6a299e67c90fac8a027a600daeeda352

## JLH Scoreによる特徴語抽出

### Significant Terms AggregationのJLH Scoreによる特徴語抽出

ElasticsearchのSignificant Terms Aggregationでは、JLHスコアを利用して特徴語抽出ができます。
下記のクエリにて、JLH Scoreにより、2003年論文のabstractの特徴語が抽出できます。
yearのところを"2004" に変えれば、2004年論文のabstractの特徴語が抽出できます。

```bash

curl -H 'Content-Type: application/json' localhost:9200/_search -d '{
    "size": 0,
    "query": {
        "term": {
            "year": "2003"
        }
    },
    "aggs": {
        "significant_terms_result": {
            "significant_terms": {
                "size": 5,
                "min_doc_count": 3,
                "field": "abstract"
            }
        }
    }
}'
```

2003年JLHスコアにより抽出された論文の特徴語は下記の通りでした。

```
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 748,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "significant_terms_result": {
      "doc_count": 748,
      "bg_count": 10000,
      "buckets": [
        {
          "key": "preplogic",
          "doc_count": 10,
          "score": 0.16536074809116647,
          "bg_count": 10
        },
        {
          "key": "2003",
          "doc_count": 27,
          "score": 0.15273559065358508,
          "bg_count": 69
        },
        {
          "key": "cd",
          "doc_count": 26,
          "score": 0.1201397428198309,
          "bg_count": 78
        },
        {
          "key": "pass",
          "doc_count": 12,
          "score": 0.10651474979880143,
          "bg_count": 21
        },
        {
          "key": "joggers",
          "doc_count": 8,
          "score": 0.10369184134519144,
          "bg_count": 10
        }
      ]
    }
  }
}
```


2004年JLHスコアにより抽出された論文の特徴語は下記の通りでした。

```
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 802,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "significant_terms_result": {
      "doc_count": 802,
      "bg_count": 10000,
      "buckets": [
        {
          "key": "geometric",
          "doc_count": 14,
          "score": 0.07488439032397091,
          "bg_count": 33
        },
        {
          "key": "cad",
          "doc_count": 6,
          "score": 0.06248095472043083,
          "bg_count": 8
        },
        {
          "key": "mesh",
          "doc_count": 9,
          "score": 0.041249743471744586,
          "bg_count": 24
        },
        {
          "key": "boundary",
          "doc_count": 10,
          "score": 0.037683323667341985,
          "bg_count": 31
        },
        {
          "key": "subdivision",
          "doc_count": 4,
          "score": 0.03647158081521052,
          "bg_count": 6
        }
      ]
    }
  }
}
```
2003年のトップの `preplogic` というのはちょっとよくわからないですね。
調べてみたところ、ネットワーク関係の会社名(?)のようでした。
2004年のトップは、 `geometric` とのことで、幾何学とかが流行ってたんでしょうか・・・

### 手計算によるJLHスコア算出

たいして難しい計算でもないので、手計算で `geometric` のスコアを出してみます。

#### 2004年のgeometricの件数

```bash
curl -H 'Content-Type: application/json' localhost:9200/_count -d '{
    "query": {
        "bool": {
            "must": [
                {
                    "term": {
                        "year": "2004"
                    }
                },
                {
                    "match": {
                        "abstract": "geometric"
                    }
                }
            ]
        }
    }
}'
```
結果は14件

#### 2004年のドキュメント件数

```bash
curl -H 'Content-Type: application/json' localhost:9200/_count -d '{
    "query": {
        "bool": {
            "must": [
                {
                    "term": {
                        "year": "2004"
                    }
                }
            ]
        }
    }
}'
```
結果は802件

#### ドキュメント全体でのgeometricの件数

```bash
curl -H 'Content-Type: application/json' localhost:9200/_count -d '{
    "query": {
        "bool": {
            "must": [
                {
                    "match": {
                        "abstract": "geometric"
                    }
                }
            ]
        }
    }
}'
```
結果は33件

全体のドキュメント件数は10,000件です。

`geometric` のJLH Scoreは
`JLH Score(geometric) = (14/802 - 33/10000) × (14/802/(33/10000)) = 0.07488439`
となり、Significant Terms Aggregationの結果と一致します。

## Custom Scoreによる特徴語抽出

ElasticsearchのSignificant Terms Aggregationでは、JLHスコアの他にも Mutual Information、Chi square、Google normalized distance、Percentage を利用して特徴語抽出ができますが、ここではPlainless Scriptを利用した Custom Scoreでの特徴語抽出を試してみます。
まずは、Painless ScriptでJLH Scoreを追実装してみます。
Custom Scoreでは下記のパラメータが利用できます。

* params._subset_size: 指定範囲のドキュメント数
* params._superset_size: 全体でのドキュメント数
* params._subset_freq: 指定範囲での単語の出現数
* params._superset_freq: 全体での単語の出現数

```java
long sbs = params._subset_size.longValue();
long sps = params._superset_size.longValue();
long sbf = params._subset_freq.longValue();
long spf = params._superset_freq.longValue();
if (sbs > 0 && sps > 0 && sbf > 0 && spf > 0) {
  double sbr = sbf / (double) sbs;
  double spr = spf / (double) sps;
  return (sbr - spr) * (sbr / spr);
} else {
  return 0.0;
}
```
これを一行にまとめてjlh-scoreというIDでElasticsearchに保存します・・・。保存すると、Elasticsearch内でスクリプトがコンパイルされます。(複数行のまま保存する方法ないんだろうか)

```bash
curl -XPUT -H 'Content-Type: application/json' localhost:9200/_scripts/jlh-score -d '{
    "script": {
    "lang": "painless",
    "source": "long sbs = params._subset_size.longValue();long sps = params._superset_size.longValue();long sbf = params._subset_freq.longValue();long spf = params._superset_freq.longValue();if ( sbs > 0 && sps > 0 && sbf > 0 && spf > 0) {  double sbr = sbf / (double) sbs;  double spr = spf / (double) sps;  return  (sbr - spr) * (sbr / spr);} else {  return 0.0;}"
  }
}'
```
下記で登録したスクリプトを利用して特徴語抽出ができます。

```bash
curl -H 'Content-Type: application/json' localhost:9200/_search -d '{
    "size": 0,
    "query": {
        "term": {
            "year": "2004"
        }
    },
    "aggs": {
        "significant_terms_result": {
            "significant_terms": {
                "size": 5,
                "field": "abstract",
                "script_heuristic": {
                    "script": {
                        "stored": "jlh-score"
                    }
                }
            }
        }
    }
}'
```

JLHスコアで特徴語抽出したときと同じ結果がレスポンスされます。で・・・オリジナルのスコアを考えたいのですが・・・良い案も思いつかないので、今回は適当に下記としてみます・・・。

```java
# 変数αは外から与える
# Custom Score = pow(指定範囲のドキュメントの出現数, α) × 絶対割合変化 × 相対割合変化

long sbs = params._subset_size.longValue();
long sps = params._superset_size.longValue();
long sbf = params._subset_freq.longValue();
long spf = params._superset_freq.longValue();
if (sbs > 0 && sps > 0 && sbf > 0 && spf > 0) {
  double sbr = sbf / (double) sbs;
  double spr = spf / (double) sps;
  return Math.pow(sbf, params.alpha) * (sbr - spr) * (sbr / spr);
} else {
  return 0.0;
}
```
ID: custom-scoreで保存します。

```bash
curl -XPUT -H 'Content-Type: application/json' localhost:9200/_scripts/custom-score -d '{
    "script": {
    "lang": "painless",
    "source": "long sbs = params._subset_size.longValue();long sps = params._superset_size.longValue();long sbf = params._subset_freq.longValue();long spf = params._superset_freq.longValue();if ( sbs > 0 && sps > 0 && sbf > 0 && spf > 0) {  double sbr = sbf / (double) sbs;  double spr = spf / (double) sps;  return  Math.pow(sbf, params.alpha) * (sbr - spr) * (sbr / spr);} else {  return 0.0;}"
  }
}'
```
alphaに2を指定しつつ、custom-scoreを利用した特徴語を実行するときの例です。
下記のalphaの値を大きくすると、指定範囲での頻度が大きい単語が上位に上がりやすくなります。

```bash
curl -H 'Content-Type: application/json' localhost:9200/_search -d '{
    "size": 0,
    "query": {
        "term": {
            "year": "2004"
        }
    },
    "aggs": {
        "significant_terms_result": {
            "significant_terms": {
                "size": 5,
                "field": "abstract",
                "script_heuristic": {
                    "script": {
                    	"params": {
                            "alpha": 2
                        },
                        "stored": "custom-score"
                    }
                }
            }
        }
    }
}'
```

```
{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 802,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "significant_terms_result": {
      "doc_count": 802,
      "bg_count": 10000,
      "buckets": [
        {
          "key": "you",
          "doc_count": 42,
          "score": 15.12774174289961,
          "bg_count": 450
        },
        {
          "key": "geometric",
          "doc_count": 14,
          "score": 14.6773405034983,
          "bg_count": 33
        },
        {
          "key": "guide",
          "doc_count": 29,
          "score": 8.038082622472649,
          "bg_count": 286
        },
        {
          "key": "3d",
          "doc_count": 13,
          "score": 5.050820197613765,
          "bg_count": 57
        },
        {
          "key": "boundary",
          "doc_count": 10,
          "score": 3.768332366734198,
          "bg_count": 31
        }
      ]
    }
  }
}

```

## まとめ

以上がElasticsearchのSignigicant Terms AggregationのCustom Scoreによる特徴語抽出の例でした。
スクリプトの外部からパラメータを与えたりもできるようなので、結構色々なことができそうな感じもしますね。
予め用意している指標で満足のいく結果が得られないときは、Custom Scoreを利用してみるのもありかも知れません。

明日は、@mpayu2さんの記事になります。お楽しみに！