MeCabをブーストさせよう(Google Search Console編)

初めまして。WACULでデータサイエンティストをしている @knknkn1162 です。
半年くらい前に[MeCabをブーストさせよう](https://qiita.com/knknkn1162/items/8c12f42dd167aae01c02)という記事を書いて、未だにいいねがつくので、そのお話の延長として、今回の記事を書きました。同様にMeCabをブーストさせる趣旨の記事ですが、違うツール(Google Search Console API)を用います。得られる効果はこちらのほうが高めです。


## 動機

半年くらい前の記事はどういうお話かというと、
MeCab をちょっと触ってみたが、`mecab-ipadic-neologd`でも足りねぇ、もうちょっと新語や特殊語彙を踏まえて分かち書きしたいという動機のもと、サイトのスクレイピング[^diffbot] + `GCP Natural Language API`　で新語を取得して、`MeCab`で新語を登録する方法の紹介でした。

当初はこの方法でのアプローチを用いていて、精度的は十分なものでした。このアプローチの大きな利点としては、新語習得の過程で余計な(意味をなさない)ワードが混じってしまっても、MeCabで分かち書きする対象の文章が病的でない限り、分かち書きにほとんど影響を与えない(つまり、新語を登録すればするほど、プラスの方向に蓄積される)点です。
ですが、Google Search Console API **のみ** を使って、新語や特殊語彙を取得できる方法を思いついてからは、次節に述べるメリットが大きいので、そちらメインにしています。(`MeCab`で新語を登録する方法は変わらないです。なので、上記の使い勝手の良いメリットは引き継がれます)

ここで、google search consoleをご存じない方に説明すると、サイト内のGoogle 検索結果の一覧を監視、管理できるツールです。また、google search console APIは自身のサイトでgoogle検索から流入するキーワード[^dimension]や表示回数(impressions)などを取得できます[^search_console]。

[^dimension]: dimension="query"として、POSTします。

[^search_console]: 詳しくは、https://developers.google.com/webmaster-tools/search-console-api-original/v3/searchanalytics/query?hl=ja を参照してください。

## メリット

さて、前記事の手法と比較したメリットは以下の三点です：

1. 複合語にめっちゃ強い(例: SEOブログ, web 分析, search console, 〇〇高校, 音声認識ソフト...などなどを1語として判定可能) GCP Natural Languageで処理してもなお、2語としてしか判定されそうもないが、2語の結合度が強いものについては、まるっと1語にできる。また、英単語2語以上からなる名詞も一語と扱える。

2. 本手法はGCP Natural Languageを用いていないので、コストフリー。(Google Search Consoleは無料のツール)

3. 本手法はスクレイピングも不要。前記事の手法だと、スクレイピングが意外と時間食う(とくに中規模以上のサイトの場合)

特に、1の威力がめっちゃ大きいです。GCP Natural Languageでもちょっと専門的な英単語群になると結構ボロボロだったりするのですが、本手法で取りこぼすことがほぼほぼなくなりました。


## 手法

ざっくり以下のようにやります：

1. [search console API](https://developers.google.com/webmaster-tools/search-console-api-original/v3/searchanalytics/query?hl=ja)に従って、dimensionを`"dimensions": ["query"]`として、POSTします。 outputはhttps://developers.google.com/webmaster-tools/search-console-api-original/v3/how-tos/search_analytics#top-10-queries-sorted-by-click-count-descending みたいな感じ。もちろん、日本語のサイトなら、日本語のクエリが取得できる。

2. 1で習得したデータのクエリは`google analytics 設定`や、`web 解析 人工知能`のように複数の語の組み合わせとなっている。これを空文字列を含め、2語に分離して、新語として取得する。例えば、`google analytics`&`設定`と分離すべきだし、`web 解析`&`人工知能`となるようにする。

3. 新語を取得できれば、後は`MeCab`で新語を登録するだけ。

----------

1. に関しては、urlを参照していただいて、3については、[前記事](https://qiita.com/knknkn1162/items/8c12f42dd167aae01c02#%E6%96%B0%E8%AA%9E%E3%82%92chasen%E5%BD%A2%E5%BC%8F%E3%81%AEcsv%E3%81%AB%E4%BF%9D%E5%AD%98)で書いているので、省略します。なので、本記事では2だけ詳しく説明しますね:sunny:

### 新語獲得のエッセンス

実装から入る前に、考え方のエッセンスを紹介します。
先ず、search consoleの指標に、`Impressions`があるのですが、今回はそれのみを用います。そもそも、Impressions(表示回数)とは、あるクエリ(キーワード)でgoogle検索結果に表示された回数を指します[^clicks]。表示回数はどれくらいそのキーワードが検索されたか、の指標とざっくり捉えて良いでしょう。このImpressionなるものを以下のように解釈します。

つまり、
「*検索される回数が多ければ多いほど、その語がイディオムとして(一語として)認識されている*」

という解釈をします。
何が狙いなのかというと、一定数以上のユーザーがイディオムのようにgoogle検索しているワードについては、まるっと一語と判定しても良いんじゃないか、との考え方をしています。
今回は、表示回数が一定以上のクエリであれば、1語とみなして、そうでなければ、2語に分解する[^multiple]というステップを踏みます。

具体的な例をあげましょう。
検索キーワードが例えば、`google analytics 設定`だったら、`google analytics`のほうが表示回数は多いはずで(実際多い)、`google analytics 設定`の表示回数は`google analytics`に比べるとがくんと下がるはずです。イメージ的には、下表のような感じ：

|query|Impressions|
| --- | --- |
| `google` | 200 |
| `google analytics` | 100 |
| `google analytics 設定` | 15 |
| `google analytics seo対策` | 10 |

簡単のため、これがすべてのデータと仮定します。ある語に着目して総Impressionsをカウントしましょう。

すると、こんな感じになります：

|query|総Impressions|
| --- | --- |
| `google` | 200+100+15+10=325 |
| `google analytics` | 100+15+10=125 |
| `google analytics 設定` | 15 |
| `google analytics seo対策` | 10 |

この後は、総Impressionsが$x$以上の場合は、一語とみなす、みたいな簡単な方法で、かなり精度良く分解できてしまいます[^imp_x]。例えば、$x=50$とすると、`google`, `google analytics`が一語と判定されます。後は、最長マッチするような分解をすれば所要のものが得られます。つまり、`google analytics`&`seo対策`として分解できるというわけです。分解した後は、3のステップに進みます。
(3のステップでは、新しい語かどうかの判別も行っていることに留意する)

[^imp_x]: $x$の決め方などは、実装を見ても分かるのですが、簡易にやっているだけで精度がでるので、あんまり神経質になる必要はないです。

[^multiple]: 別に、3語以上にも再帰的に処理すれば分解できるのですが、今回は簡単のために、2語に分解するまでのステップのみお話します。

### 実装

こんな感じ：

```python

from collections import Counter
from copy import deepcopy
from itertools import chain

class QuerySplitter():
    """
    クエリ表示回数に基づく分かち書きを行う
    """
    def __init__(self, query_metrics, threashold_rate=0.005, max_threashold=500):
        self.query_metrics = query_metrics
        self.threashold_rate = threashold_rate
        self.max_threashold = max_threashold
        
    def impressions_threshold(self):
        """calc via total impressions"""
        all_impressions = sum(self.query_metrics["impressions"])

        return min(all_impressions * self.threashold_rate, self.max_threashold)

    def integrate_impressions(self):
        """
        検索語ごとのimpressionsを数え上げて単語候補ごとのimpressionsに変換する
            * 各クエリを以下の単位で単語候補に分割する
                * クエリ全体
                * クエリ内の語（スペース区切り）をそのまま潰したもの
                * クエリ内の語
            * 他のクエリと最長マッチする単語候補にimpressionsを集計する
        output: { word1: n1, word2: n2... }
        ここの処理は重いので、2重loopをdictベースにしている
        """
        counter = Counter()
        # DataFrame展開は遅いので、dictにしておく
        dic = dict(zip(self.query_metrics["query"], self.query_metrics["impressions"]))
        i = 0
        for query, impressions in dic.items():
            candidates = word_candidates(query)

            # :memo: 効率化するために1行あたりのCounterを作って最後にマージする
            cands_counter = Counter()
            for another_query, another_impressions in dic.items():
                for candidate in candidates:
                    if candidate in another_query:
                        cands_counter += {candidate: another_impressions}
                        break
            counter += cands_counter

        return counter

    def build_word_dict(self):
        """
        検索語群から表示回数に基いて単語辞書を構成する
        output: [ words ]
        """
        candidates_counter = self.integrate_impressions()

        # AAABBBのようなwordが見つかった時、AAA BBBのようなクエリもwordとして登録するため、
        # AAABBB => [AAA BBB] となるdictを作っておく
        mashed_queries = defaultdict(set)
        impressions_threshold = self.impressions_threshold()
        for name, row in self.query_metrics.iterrows():
            query = row['query']
            mashedQuery = row['query'].replace(" ", "")
            mashed_queries[mashedQuery].add(query)

        # 閾値を超えたものを単語として選択する
        words = set()
        for candidate, impressions in candidates_counter.most_common():
            if impressions >= impressions_threshold:
                # 足し上げ結果が閾値を超えたら辞書に登録
                words |= {*mashed_queries[candidate], candidate}

        return sorted(words, key=lambda s: -len(s))

    def extract_words(self):
        """1語と判定された語彙を抜き出す"""
        return sorted(set(chain.from_iterable(q.values() for q in self.split_queries())), key=lambda s: -len(s))[:-1]
        
    def split_queries(self):
        """
        単語辞書で分かち書きを行う
        output: [ { query, [fixedPartialQuery], basedPartialQuery, impressions } ]
        """
        words = self.build_word_dict()

        queries = []
        for name, row in self.query_metrics.iterrows():
            splited_query = deepcopy(row)
            splited_query = self.split_query(row, words)
            queries.append(splited_query)
        return queries

    def split_query(self, query_metric, words):
        query = query_metric['query']

        # スペースなしのクエリは分かち書きをしない
        if " " not in query:
            return {'fixedPartialQuery': "", 'basedPartialQuery': query}

        chunks = query.split(" ", 2)
        fixed_query = chunks[0]
        fixed_query_length = len(fixed_query)

        for word in words:
            if len(word) <= fixed_query_length:
                break

            for w in [word, word.replace(" ", "")]:
                if len(w) <= fixed_query_length:
                    # 一語の塊のほうが大きかったら、そっちを優先する
                    continue

                if query.startswith(w):
                    # chunk(スペース区切り内の文字列)の途中で
                    # 切らずchunkの区切りまで伸ばす
                    next_space = query.find(' ', len(w))
                    if next_space == -1:
                        return {'fixedPartialQuery': "", 'basedPartialQuery': query}
                    return {'fixedPartialQuery': query[:next_space], 'basedPartialQuery': query[next_space:].strip()}
        return {'fixedPartialQuery': fixed_query, 'basedPartialQuery': chunks[1]}

    
def word_candidates(query):
    candidates = [query]
    if " " in query:
        candidates.append(query.replace(" ", ""))
        candidates.extend(sorted(query.split(" "), key=lambda s: -len(s)))
    return candidates
```

で、

```python
# assume that isinstance(df, DataFrame) and df.columns == ["query", "impressions"]
obj = QuerySplitter(df)
wordlist = obj.extract_words()
```

とすれば動作します。

`split_queries`でクエリの分かち書きを行った後に、ワードの抜き出しを行っています。`integrate_impressions`の2重ループは重いのですが、それでも`len(df)==10000`なら1分で捌き切る感じです。あとは、検索キーワードにスペース入っていなければ、途中で分割せずに取ってくる(チャンクを意識してとる)とかしてますが、大まかには`integrate_impressions`がエッセンスの部分に相当します。
`impressions_threshold `で一語とみなす条件を定めています。

やってることは単純ですが、「大体の人がおんなじように検索しているワード」を一語というふうに解釈したことで、直感的な語彙の結果が得られやすく、特に、英語を含む複合語のキャッチがほぼ思い通りで気持ちいい。


[^clicks]: ちなみに、clicksは、あるクエリ(キーワード)で該当サイトにアクセスされた回数を指します。


[^diffbot]: 弊社では、記事のスクレイピングにdiffbotというサービスを使用しています。生のhtmlだと、広告とかの除去判定が大変なのですが、そのあたりよしなに、記事にあたる部分のみを抽出してくれます。

## 最後に

本記事の新語取得のアルゴリズムの考え方自体は、シンプルなのですが、精度の点では前記事の手法よりむしろ良くなっているくらいです。
メリットしか言っていなかったので、デメリットも紹介すると、search consoleに頼った手法なので、他サイトからの情報を取ってきたい、とかは残念ながらできません。ですが、使いようによっては結構うまくハマると思います。例えば、自社で運営しているサービスやサイトに対して、このような手法を目標達成の一部として用いると幸せになれるかもしれません:heart:。