Google AdWords APIと機械学習（KMeans）を使ってWebページのキーワードをクラスタリングする

[XTechグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/xtech-group) の16日目を担当する、エキサイト株式会社新卒1年目の吉川です。


# はじめに
[Google広告](https://ads.google.com/) の中に「キーワードプランナー」というツールがあります。ツールでは文字列かURLで検索を行うことができ、それぞれ以下のような結果が取得できます。
- 文字列で検索：検索した文字列から連想（サジェスト）される単語を取得する
- URLで検索：URLが示すWebページから何らかのキーワードを抽出して取得する

今回はURLでの検索からキーワードを抽出し、さらにそのキーワードを機械学習によって分類することを紹介します。

# キーワードプランナーを使ってみよう
いきなりAPIを使ってみるのも良いですが、全体像の把握のためにキーワードプランナーそのものの紹介から書こうと思います。早く本題に入ってくれ、という方は読み飛ばしてください。

まずは[Google広告](https://ads.google.com/) にログインして（**別途利用登録が必要です**）、上部のバーから「ツールと設定」を選択します。
![Google広告の上部バー](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908287/28aff4d4-89b0-bd41-e6b0-74fe1f53acb4.png "Google広告の上部バー")


次に下に出てくるパネルの中から「キーワードプランナー」を選択します。
![キーワードプランナー](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908287/32e60e84-e7fd-d31c-c498-d3aa40297345.png "キーワードプランナー")


次に「ウェブサイトから開始」を選択し、お好みのURLを入力して「結果を表示」をクリックします。下にラジオボタンがありますが、サイト（ドメイン）全体からキーワードを取得するか、入力したURLのページのみからキーワードを取得するかが選べます。どちらも一旦試してみると違いがわかるかと思います。
![ウェブサイトから開始](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908287/0db4f8ba-4dfc-05ca-25d5-0beafa7d05cb.png "ウェブサイトから開始")

検索結果は以下のようになります。今回は[エキサイトトップページ](https://www.excite.co.jp/) で検索してみました。
![検索結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908287/b1ef5fc3-880e-964c-6d84-32373f51aea0.png "検索結果")


検索結果には抽出したキーワード複数個と、それぞれのキーワードの検索ボリュームや競合性、広告価格帯などが表示されます。どうやらエキサイト翻訳に関するキーワードが検索ボリュームが高いようです。

# Google AdWords APIでキーワードプランナーの情報を取得する
上記のことをAPIでも行います。使用したのは[Google AdWords API](https://developers.google.com/adwords/api/docs/reference/release-notes/v201809) です。安定性を踏まえて少し古いバージョンを使用しています。コードはPythonを使用しています。

ライブラリは以下のコマンドからインストールしてください。

```console:console
pip3 install googleads

(本題からはそれますが、以下のライブラリも使用しています)
pip3 install boto3 PyYAML
```

## AdWords APIの初期設定
マネージャーアカウントでGoogle広告にログイン→APIセンターを表示して（マネージャーアカウントでないと表示されないようです）APIトークンを確認、あるいは新規取得します。

その後トークン情報をコード上に実装していきます。git等でコードを共有する場合、トークン情報をそのままコードに記載するのは危険なので、AWS Systems Managerのパラメータストアを経由します。

![AWS Systems Manager](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908287/cc97f605-7261-67ac-b354-e9bef5a3bfeb.png "AWS Systems Manager")

```python:main.py
"""
予めマネージャーアカウントからAPIトークンの各種情報を確認し、AWS Systems Managerのパラメータストアにキーと値のペアで保存しておく。
またローカル環境から実行できるように、IAMユーザーのアクセスキーIDとシークレットアクセスキーを./aws/credentialsに保存しておく
"""

# パラメータストアから値を取得
ssm = boto3.client('ssm')
response = ssm.get_parameters(Names=[
    '<デベロッパートークン情報と紐づくキー>',
    '<ユーザーエージェント情報と紐づくキー>',
    '<クライアントカスタマー情報と紐づくキー>'
    '<クライアントID情報と紐づくキー>',
    '<クライアントシークレット情報と紐づくキー>',
    '<リフレッシュトークン情報と紐づくキー>'
], WithDecryption=True)

# 必要なパラメータを抽出してdictにする
googleads_yml = {
    'adwords': {
        'developer_token': list(filter(lambda x: x['Name'] == '<デベロッパートークン情報と紐づくキー>', response['Parameters']))[0]['Value'],
        'user_agent': list(filter(lambda x: x['Name'] == '<ユーザーエージェント情報と紐づくキー>', response['Parameters']))[0]['Value'],
        'client_customer_id': list(filter(lambda x: x['Name'] == '<クライアントカスタマー情報と紐づくキー>', response['Parameters']))[0]['Value'],
        'client_id': list(filter(lambda x: x['Name'] == '<クライアントID情報と紐づくキー>', response['Parameters']))[0]['Value'],
        'client_secret': list(filter(lambda x: x['Name'] == '<クライアントシークレット情報と紐づくキー>', response['Parameters']))[0]['Value'],
        'refresh_token': list(filter(lambda x: x['Name'] == '<リフレッシュトークン情報と紐づくキー>', response['Parameters']))[0]['Value']
    }
}

# 初期化（dictをyamlに変換して引数にする）
adwords_client = adwords.AdWordsClient.LoadFromString(yaml.dump(googleads_yml))
targeting_idea_service = adwords_client.GetService('TargetingIdeaService', version='v201809')
```

## URLからキーワードを取得する
AdWordsAPIのうち、[こちら](https://developers.google.com/adwords/api/docs/reference/v201809/TargetingIdeaService.TargetingIdeaSelector) に記載されているフィールド中の`requestedAttributeTypes`にある属性について、URLから情報を取得します。ブラウザ上で行った時と同様、エキサイトトップページを対象にします。

セレクタに用いるパラメータを設定した後、APIにリクエストを投げています。取得したい属性はlistで指定します。

```python:main.py
# 対象とするURLを設定
url = 'https://www.excite.co.jp/'

# セレクタの設定
selector = {
    'ideaType': 'KEYWORD',
    'requestType': 'IDEAS',
    'requestedAttributeTypes': ['KEYWORD_TEXT', 'SEARCH_VOLUME', 'COMPETITION', 'AVERAGE_CPC'],  # 取得したい属性を設定
    'paging': {'startIndex': 0, 'numberResults': 20},  # 取得するキーワードの始点と合計の件数
    'searchParameters': [
        {
            'xsi_type': 'RelatedToUrlSearchParameter',  # URLからキーワードを検索する
            'urls': url,  # 対象とするURL
            'includeSubUrls': "false"  # URLのページからのみキーワードを抽出する
        },
        {
            'xsi_type': 'LanguageSearchParameter',
            'languages': [{'id': '1005'}]  # 日本語
        },
        {
            'xsi_type': 'LocationSearchParameter',
            'locations': [{'id': '2392'}]  # 日本
        },
        {
            'xsi_type': 'NetworkSearchParameter',
            'networkSetting': {
                'targetGoogleSearch': True,
                'targetSearchNetwork': False,
                'targetContentNetwork': False,
                'targetPartnerSearchNetwork': False
            }
        }
    ]
}
targeting_idea_service_result = targeting_idea_service.get(selector)

"""
確認
print(targeting_idea_service_result)

出力結果
{
    'totalNumEntries': 99,
    'entries': [
        {
            'data': [
                {
                    'key': 'KEYWORD_TEXT',
                    'value': {
                        'Attribute.Type': 'StringAttribute',
                        'value': 'エキサイト 翻訳'
                    }
                },
                {
                    'key': 'COMPETITION',
                    'value': {
                        'Attribute.Type': 'DoubleAttribute',
                        'value': 0.123456789
                    }
                },
                {
                    'key': 'AVERAGE_CPC',
                    'value': {
                        'Attribute.Type': 'MoneyAttribute',
                        'value': {
                            'ComparableValue.Type': 'Money',
                            'microAmount': 1234567890
                        }
                    }
                },
                {
                    'key': 'SEARCH_VOLUME',
                    'value': {
                        'Attribute.Type': 'LongAttribute',
                        'value': 12345
                    }
                }
            ]
        },
        {
            以下同様
        },
    ]
}
"""

```
AdWordsAPIには`RateExceededError`というエラーものがあり、APIへのリクエストが短時間に何度も実行されると処理が中断して落ちてしまうことがあります。その際はSleepを挟む等の対応を追加してください。
- （参考）https://developers.google.com/adwords/api/docs/guides/rate-limits?hl=en#slow_down




# 機械学習にかけてクラスタリングする
これで任意のURLからキーワードを取得できるようになりました。取得したキーワードをさらに分析するため、機械学習を使ってクラスタリングしてみようと思います。
今回はクラスタリング結果の正解/不正解を特に想定しないケースになるので、教師なし学習ではおなじみのKMeans法を使ってクラスタリングを行います。
ライブラリは以下のコマンドからインストールしてください。

```console:console
pip3 install scikit-learn
```
## データの整形と正規化
scikit-learnを使ってKMeans法でクラスタリングする場合、2次元のlistにする必要があります。この時点で文字列情報などが入っている場合は取り除いておきます。
またクラスタリングの精度を向上させるために正規化を行うと良いです。正規化はPandasのデータフレームで言うカラム（列）ごとに行います。

```python:main.py
# APIから返却されたデータの整形（数値データのみ取り出す）
clustering_datasets = []
for entry in targeting_idea_service_result['entries']:
    clustering_dataset = []
    for data in entry['data']:
        if data['key'] in ['SEARCH_VOLUME', 'COMPETITION']:  # 'key': 'KEYWORD_TEXT'のdataは文字列情報なので除外
            clustering_dataset.append(data['value']['value'])
        elif data['key'] in ['AVERAGE_CPC']:
            if data['value']['value'] is None:  # 'key': 'AVERAGE_CPC'の結果はNoneの時がある
                clustering_dataset.append(0.0)
            else:
                clustering_dataset.append(data['value']['value']['microAmount'])
    clustering_datasets.append(clustering_dataset)

# 列ごとに正規化する
clustering_datasets = MinMaxScaler().fit_transform(clustering_datasets)
```

## クラスタリングの実行
正規化したデータを使ってクラスタリングを行います。返り値は整数のlistになります。今回は5つのクラスタに振り分けます。

```python:main.py
# KMeans法でクラスタリングする
n_cluster = 5  # クラスタの数
pred = KMeans(n_clusters=n_cluster, init='k-means++').fit_predict(clustering_datasets)

"""
結果表示
for i in range(len(targeting_idea_service_result['entries'])):
    print('キーワード: ' + targeting_idea_service_result['entries'][i]['data'][0]['value']['value'] + ' --- クラスタ: ' + str(pred[i]))

出力
キーワード: エキサイト 翻訳---クラスタ: 3
キーワード: excite 翻訳---クラスタ: 4
キーワード: 翻訳 エキサイト---クラスタ: 4
以下同様...
"""
```


- クラスタリングする際、クラスタの数を予め設定する必要があります。
何らかの条件上クラスタ数を固定して行う場合はこれでいいのですが、
もしクラスタリングするデータ（今回で言えばAdWordsAPIから取得したデータ）によってクラスタ数を可変にしたい場合は、エルボー法などを使うと良いです。
    - (参考)：https://qiita.com/panda531/items/4b5504a3949eacb7ab19
- 予め設定するクラスタ数を固定している場合、もしクラスタリングするデータ数がクラスタ数より少ない時にエラーになってしまいます。
そうなる場合はクラスタ数を減らすなどの処理を入れるとより安定した挙動になります。




# 最後に
いかがだったでしょうか。もしAdwordsAPIやKMeansを使う方がいらっしゃいましたら、この記事が何かのお役に立てれば幸いです。

XTechグループ Advent Calendar 2020もいよいよ残り10日、明日の執筆担当は[@pepota](https://qiita.com/pepota) です。お楽しみに！

弊社採用情報はこちらからどうぞ↓↓↓
https://www.wantedly.com/companies/excite