アクセス解析担当者のBigQuery日記

普段はアクセス解析やウェブ広告の運用をしています。主にBigQueryについて、記録していきます。

BigQueryで時系列データから異常部位検出 (k近傍法)

概要

手法についてはこちらの異常部位検出の解説部分が、分かりやすかったです。
www.albert2005.co.jp

用途としては、ネット広告について言うと、配信全体の異常なクリック増加・減少や、特定のキーワードの異常検知に使えると考えている。
具体的に何をやっているかと言うと、例えばある日の異常度として、その日の直近7日と、それ以前の連続した7日との距離を計算し、距離の最小値を出している。


※異常検知にはいくつか手法がありますが、k近傍法を使おうと思ったのは、BigQueryで実装が簡単そうだったからです。


なお、今回はBigQueryのUDFを使っているが、UDFについての詳細な解説はしない。
cloud.google.com

今回のゴール

異常度を計算し、データポータルで可視化

アウトプットのイメージ

f:id:sem-aa-bq:20190208225648p:plain
異常度をデータポータルで可視化

青がデータで、赤が計算された異常度。左始まりの時系列データとして処理している。左の方、即ち時系列の最初の方で異常度が高く出ているのは、計算上やむを得ない部分だが、真ん中あたりで、青いグラフがそれまでと異なる動きをした際に、それに追従して赤の異常度も高くなっている。

今回のキーワード

  1. UDF
  2. ARRAY_AGG関数
  3. ウィンドウ関数
  4. サンプルデータ生成

コードサンプル

#StandardSQL
CREATE TEMPORARY FUNCTION calc_min_distance(part_arr ARRAY<FLOAT64>,part_arr_arr ARRAY<STRUCT< inner_arr ARRAY<FLOAT64>>>) RETURNS FLOAT64
LANGUAGE js AS """
  function calc_distance(x,y) {
    if(x.length != y.length) {
      return null
    } else {
      var d = 0
      for(var i = 0;i<x.length;i++){
        d += Math.pow(x[i] - y[i],2)
      }
      return Math.sqrt(d)
    }
  }
  
  var ret = null
  if(part_arr_arr == null){
    return ret
  }
  for(var i = 0 ; i < part_arr_arr.length;i++){
    var distance = calc_distance(part_arr,part_arr_arr[i].inner_arr)
    if(distance!=null){
      if(ret==null || ret > distance){
        ret = distance
      }
    }
  }
  
  return ret
""";
WITH
  sample1 AS (
    #およそ周期が7のデータを生成する
  SELECT
    x AS id,
    3 + 10 * SIN( x * 3.14 /3.5) + RAND() AS x
  FROM
    UNNEST(GENERATE_ARRAY(0,100)) AS x ),
    sample2 AS (
    #異常値を載せたデータを作成する
  SELECT
    *,
    IF(id = 50,x + 10,x) AS abnomal_x
  FROM
    sample1
  ),
  #####################
  #k近傍法による異常検知の実装
  #####################
  t1 AS (
     #部分系列の生成
  SELECT
     *,
     ARRAY_AGG(abnomal_x) OVER(ORDER BY id asc ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS part_arr # 6 PRECEDINGを、使用したい系列の長さにより変更してください
  FROM sample2
  ),
  t2 AS (
    #比較したいウィンドウで、部分系列の系列を生成します。
    SELECT
      *,
      ARRAY_AGG(STRUCT(part_arr)) OVER(ORDER BY id asc ROWS BETWEEN 30 PRECEDING AND 1 PRECEDING) AS part_arr_arr # 30 PRECEDINGを、比較したいウィンドウサイズにより変更してください
    FROM
      t1
  )
  SELECT
    id,
    abnomal_x,
    calc_min_distance(part_arr,part_arr_arr) AS min_distance
  FROM
    t2

解説

上から順に見ていく。

UDF部分

まず、UDFで異常度の計算部分する関数を定義している。javaScriptで書いているおり詳細は省略するが、ざっくりした処理のイメージは下記である。

ざっくりした処理のイメージ
引数として、part_arrとpart_arr_arrを受け取る

part_arr = [1,2,3,2,1,4,3]
part_arr_arr = [
{inner_arr : [2,3,4,5,6,4,1]},
{inner_arr : [3,4,5,6,4,1,5]},
{inner_arr : [4,5,6,4,1,5,2]},
{inner_arr : [5,6,4,1,5,2,3]},
{inner_arr : [6,4,1,5,2,3,3]},
{inner_arr : [4,1,5,2,3,3,2]},
{inner_arr : [1,5,2,3,3,2,4]},
{inner_arr : [5,2,3,3,2,4,3]},
...
]


part_arr([1,2,3,2,1,4,3])と、part_arr_arrの1つめの要素の配列([2,3,4,5,6,4,1])を比較、距離はd1,
part_arr([1,2,3,2,1,4,3])と、part_arr_arrの2つめの要素の配列([3,4,5,6,4,1,5])を比較、距離はd2,
...

距離の最小値は dn
dnを関数の処理結果として返す

※なお、part_arr_arr内の要素をオブジェクトとして格納しているのは、BigQueryがネストしたARRAYを扱えないためである。

ARRAY<ARRAY<INT64>>
(サポート対象外)

これは無効な型の宣言です。マルチレベル ARRAY の作成方法を調べるためにこのページにアクセスした場合に備え、ここに記載しておきます。ARRAY には直接 ARRAY を含めることはできません。代わりに次の例をご覧ください。


ARRAY<STRUCT<ARRAY<INT64>>>

64 ビット整数の ARRAY からなる ARRAY。ARRAY には直接他の ARRAY を含めることができないため、2 つの ARRAY の間に STRUCT が挿入されています。


cloud.google.com

サブクエリsample1,sample2

sample1 : サンプルデータを生成しています。周期がおよそ7になるようなデータを生成しています。
sample2 : sample1で生成したデータの真ん中あたりに、異常値を乗せています。アウトプットのイメージ画像の、青い線の真ん中あたりのはねているところです。

サブクエリt1

ARRAY_AGGをウィンドウ関数で使って、直近7日(7行)の部分配列を、各行に対して生成しています(part_arr)。
cloud.google.com

サブクエリt2

t1で生成した部分配列をSTRUCTに格納したものを、再度ARRAY_AGGをウィンドウ関数で使って、その行より前の直近30行のpart_arrを更に配列に格納します(part_arr_arr)。

アウトプット部分

サブクエリt1,t2で生成したpart_arrとpart_arr_arrを、UDFに渡し、異常度として距離の最小値を得ます。

クエリ結果画面で、データポータルで調べるを押し、グラフを作成します。

f:id:sem-aa-bq:20190208225648p:plain
異常度をデータポータルで可視化



異常です。

BigQueryのSTRUCTを活用して、複数列をまとめて処理する

概要

STRUCTは慣れると便利です。今回は複数列をまとめて処理する流れとサンプルクエリを書きます。

SQLを書いて昨対比を出すことを考える。
あるタイミングの実績集計に、昨年の実績集系列もあるようなものを作成し、成長率などを計算したい。
例えばwebサイトのセッションについて、以下のようなデータを作成する。

month session session_1year_ago
2017-08-01 216 136
2017-07-01 213 126
2017-06-01 224 118
2017-05-01 208 116
2017-04-01 208 100
2017-03-01 183 94
2017-02-01 208 81
2017-01-01 182 null
2016-12-01 172 null
2016-11-01 160 null
2016-10-01 155 null
2016-09-01 146 null
2016-08-01 136 null
2016-07-01 126 null
2016-06-01 118 null
2016-05-01 116 null
2016-04-01 100 null
2016-03-01 94 null
2016-02-01 81 null

おおよその作成の流れとしては、

  1. 日毎あるいは月ごとの集計をサブクエリで作成
  2. 1で作成したサブクエリの日付を1年後に修正したサブクエリを作成
  3. 1と2をLEFT JOIN
  4. 2の方のカラム名を修正

というふうにすれば、目的のデータを作成できる。
あるいは、LAGやLEADなどのウィンドウ関数の利用によっても実現可能である。
この時カラム数の増加に応じて、4のカラム名の修正が手間になる。
また、ウィンドウ関数を使用した場合、カラムごとにウィンドウ関数を書く必要がある。
こんな感じである。

#月次データで、1年前のデータを出す
LAG(session,12) OVER(order by month ASC) AS session_1year_ago,
LAG(user,12) OVER(order by month ASC) AS user_1year_ago,
LAG(pv,12) OVER(order by month ASC) AS pv_1year_ago,
LAG(transaction,12) OVER(order by month ASC) AS transaction_1year_ago,

BigQueryではSELECT句にテーブル名だけを書くと、テーブル名のSTRUCT型の列を生成してくれる。
今回はこれを活用し、構造が同じ2つのSTRUCTを並べることで、目標のデータを作成する。

クエリ例

いつものようにGoogle Analyticsのサンプルデータセットを使う。
なお、データが1年分と少ししかないため、昨年の代わりに昨月の実績集計を出している。

#StandardSQL
WITH
  base AS (
  SELECT
    parse_DATE("%Y%m%d",
      CONCAT(SUBSTR(_TABLE_SUFFIX,1,6),"01")) AS _date,
    #_TABLE_SUFFIXを20190124 ->20190101 に変換し、日付型に変換
    COUNT(*) AS session,
    COUNT(DISTINCT fullVisitorId ) AS user,
    SUM(totals.pageviews) AS pageviews,
    SUM(totals.bounces) AS bounces,
    SUM(totals.transactions) AS transactions,
    SUM(totals.transactionRevenue) AS transactionRevenue,
    SUM(totals.newVisits) AS newVisits
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*`
  GROUP BY
    1 ),
  add_1month AS (
  SELECT
    DATE_ADD(_date, INTERVAL 1 MONTH) AS _date,
    #1ヶ月後の日付に変換
    #昨年対比にする場合は、ここを以下のようにする
    #DATE_ADD(_date , INTERVAL 1 YEAR) AS _date
    * EXCEPT(_date)
  FROM
    base )
SELECT
  base,
  add_1month AS previous_month
FROM
  base
LEFT JOIN
  add_1month
USING
  (_date)
ORDER BY base._date DESC

ウィンドウ関数を使用する場合は、以下のように1行だけ記述すれば良くなる。(※以下のようにLAGが使えるのは、すべての行が連続したデータである場合のみ。)

#standardSQL
  WITH base AS (
  SELECT
    parse_DATE("%Y%m%d",
      CONCAT(SUBSTR(_TABLE_SUFFIX,1,6),"01")) AS _date,
    #_TABLE_SUFFIXを20190124 ->20190101 に変換し、日付型に変換
    COUNT(*) AS session,
    COUNT(DISTINCT fullVisitorId ) AS user,
    SUM(totals.pageviews) AS pageviews,
    SUM(totals.bounces) AS bounces,
    SUM(totals.transactions) AS transactions,
    SUM(totals.transactionRevenue) AS transactionRevenue,
    SUM(totals.newVisits) AS newVisits
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*`
  GROUP BY
    1 )
SELECT
  base,
  LAG(base) OVER(ORDER BY base._date ASC) AS previous_month
FROM
  base
ORDER BY
  base._date DESC

f:id:sem-aa-bq:20190125235331p:plain
クエリ実行結果


クエリ実行結果をデータポータルで以下のように可視化してみる。例えばセッションのMoMは以下のようになる。

f:id:sem-aa-bq:20190126000008p:plain
データポータルでセッションのMoMを可視化

以上です。

BigQueryでエクセルのLENBを計算する(半角を1、全角を2として文字数を数える)

例えばAdwords(Google広告)へのkwや広告文の入稿データの作成を行う場合、文字数のカウントをエクセルのLENBで行うことが多いと思う。BigQuery上でも同様の文字数カウントを実現する。

LENBを出したいカラム名がkeywordの場合、以下のように書けば良い。

LENGTH(keyword)+ LENGTH(REGEXP_REPLACE(keyword,r'[ -~。-゚]','')) 

keywordの文字数(LENGTH(keyword))
と、
半角文字を削除(REGEXP_REPLACE(keyword,r'[ -~。-゚]',''))したあとに残った文字(=全角文字)の数

を足している。

確認用sql

#StandardSQL
WITH
  test_data AS (
  SELECT
    *
  FROM
    UNNEST(
    [
      "あいうeo",
      "カき く",
      "keko",
      "Google 広告"
    ]) AS keyword )
SELECT
  keyword,
  LENGTH(keyword), #単純な文字数
  REGEXP_REPLACE(keyword,r'[ -~。-゚]','') , #半角を空文字に置換
  LENGTH(REGEXP_REPLACE(keyword,r'[ -~。-゚]','')), #半角を空文字に置換した文字列の長さ
  LENGTH(keyword) +   LENGTH(REGEXP_REPLACE(keyword,r'[ -~。-゚]','')) # 半角を空文字に置換して、残った文字数を更に足す。
  AS lenB
FROM
  test_data

BigQuery ML ロジスティック回帰で、次のセッションの注文を予測する

概要

以下のチュートリアルをやってみた。
Getting started with BigQuery ML for data analysts  |  BigQuery  |  Google Cloud
これを少しアレンジして、
Google Analyticsのデータから、次のセッションでcv(transaction)が発生するか、BigQuery ML のロジスティック回帰で 予測を行ってみる。

モチベーションとしては、例えばリマーケティング広告や販促メールの送付先を検討するには、次のセッションでコンバージョンが発生しそうかどうかに意味があると考えたためです。

チュートリアル同様、google_analytics_sample データを利用する。

ステップ1: 準備

データセットの作成

チュートリアルと同様に、bqml_tutorial データセットを使用する。こちらに完成したモデルを格納する。

変数として追加出来そうなデータを探索する

チュートリアルで使用された変数以外に、google_analytics_sample で使用できそうなデータを探す。ぱっと思いつくのは

  • 特定のページを見たかどうか(あるいは見た回数)
  • 特定のイベントが発生したかどうか(あるいは発生した回数)

など

ページについては、サイト構造をよく理解していないので今回は使用しない。
発生しているイベントを調べてみる。

#StandardSql
SELECT
  hits.eventInfo.eventAction,
  hits.eventInfo.eventCategory,
  hits.eventInfo.eventLabel,
  COUNT(*) AS event_cnt
FROM
  `bigquery-public-data.google_analytics_sample.ga_sessions_20170801` AS ga,
  ga.hits AS hits
GROUP BY
  1,
  2,
  3
ORDER BY
  4 DESC

結果がこちら

EventActionにAdd to Cart などそれっぽいものがある。
他のEventActionも含め、セッションで特定のイベントが発生したがどうか、bool型のカラムを作成することにする。

ステップ2: モデルを作成する

次のセッションでtransactionが発生するかどうかが今回の目的変数なので、LEAD関数を使用する。
Standard SQL Functions & Operators  |  BigQuery  |  Google Cloud

また、今回は次のセッションが発生しなかったユーザーは除外した。
モデルの格納先は、bqml_tutorial.model001_nextSessionTransaction。

#standardSQL
CREATE MODEL `bqml_tutorial.model001_nextSessionTransaction`
OPTIONS(model_type='logistic_reg') AS (
  SELECT
  #次のセッションが発生したセッションに絞る
    *
  FROM (
    SELECT
      #今回の目的変数。次のセッションでtransactionが発生するかどうか。
      LEAD(IF(totals.transactions >0,1,0 )) OVER(partition by fullVisitorId  ORDER BY visitStartTime)  AS label,
      #以下が組成。このセッションでの行動から作成する。
      IF(totals.transactions IS NULL, 0, 1) AS transaction,
      IFNULL(device.operatingSystem, "") AS os,
      device.isMobile AS is_mobile,
      IFNULL(geoNetwork.country, "") AS country,
      IFNULL(totals.pageviews, 0) AS pageviews,
      IFNULL(totals.bounces, 0) AS bounces,
      IFNULL(totals.timeOnSite , 0) AS timeOnSite,
      IFNULL(totals.newVisits  , 0) AS newVisits,
      #session内で、特定のeventActionが存在するかどうかを判定
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Quickview Click") AS is_qc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Add to Cart") AS is_ac,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Product Click") AS is_pc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Remove from Cart") AS is_rc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Promotion Click") AS is_prmc
    FROM
      `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
    WHERE
      _TABLE_SUFFIX BETWEEN '20160801' AND '20170630'
  )
  WHERE label IS NOT NULL
)

ステップ3: モデルを確認する

bqml_tutorial データセットに移動し、先ほど作成したモデル model001_nextSessionTransaction を選択肢する。

損失が徐々に減っていっていることが確認できる。

係数のweightを確認する。

また、ML.WEIGHTS関数を使用して、各係数に振られている重みを確認できる。カテゴリ変数と数値変数のweightをまとめ、weightが大きい順に並べてみる。

WITH
  base AS (
  SELECT
    *
  FROM
    ML.WEIGHTS(MODEL `bqml_tutorial.model001_nextSessionTransaction`)),
  num_var AS (
  #数値変数のみ
  SELECT
    processed_input,
    "---" AS category,
    weight
  FROM
    base
  WHERE
    weight IS NOT NULL ),
  cat_var AS (
  #カテゴリ変数のみ
  SELECT
    processed_input,
    category_weights.category,
    COALESCE(base.weight,
      category_weights.weight) AS weight
  FROM
    base,
    base.category_weights AS category_weights )
SELECT
  *
FROM
  num_var
UNION ALL (
  SELECT
    *
  FROM
    cat_var)
ORDER BY
  weight DESC

結果がこちら。

上の方だけ見ると、

  • is_ac(Add to Cartが発生した)
  • countryがNicaragua
  • countryがVenezuela
  • osがChrome OS
  • pageviewsが多い

というセッションについて、次のセッションのtransaction発生率が高いと言うモデルになっているみたい。

ステップ4: モデルを評価する

ML.EVALUATE 関数を使用して、性能を評価する。

#standardSQL
SELECT
  *
FROM
  ML.EVALUATE(MODEL  `bqml_tutorial.model001_nextSessionTransaction`,
 (
  SELECT
  #次のセッションが発生したセッションに絞る
    *
  FROM (
    SELECT
      #今回の目的変数。次のセッションでtransactionするかどうか。
      LEAD(IF(totals.transactions >0,1,0 )) OVER(partition by fullVisitorId  ORDER BY visitStartTime)  AS label,
      #以下が組成。このセッションでの行動から作成する。
      IF(totals.transactions IS NULL, 0, 1) AS transaction,
      IFNULL(device.operatingSystem, "") AS os,
      device.isMobile AS is_mobile,
      IFNULL(geoNetwork.country, "") AS country,
      IFNULL(totals.pageviews, 0) AS pageviews,
      IFNULL(totals.bounces, 0) AS bounces,
      IFNULL(totals.timeOnSite , 0) AS timeOnSite,
      IFNULL(totals.newVisits  , 0) AS newVisits,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Quickview Click") AS is_qc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Add to Cart") AS is_ac,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Product Click") AS is_pc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Remove from Cart") AS is_rc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Promotion Click") AS is_prmc
    FROM
      `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
    WHERE
      _TABLE_SUFFIX BETWEEN '20170701' AND '20170801'
  )
  WHERE label IS NOT NULL
)
)

結果がこちら

precisionがNaN、recallが0.0 となっている。
これはすべてのデータについて、目的変数(次のセッションでtransactionが発生するかどうか)が最終的には0で予測されているためです。
一方で、roc_aucは0.822となっている。目的変数(次のセッションでtransactionが発生するかどうか)が1である確率が高いデータ順に並べてデータを取り出すとき、実際にtransactionが発生しているデータを(無作為に取り出すより)効率よく取り出すことができている。

なお、各カラムの詳細については以下をご確認ください。
https://cloud.google.com/bigquery/docs/bigqueryml-analyst-start#run_the_mlevaluate_query

また、transactionするか(=1)しないか(=0)の2値分類ではなく、transactionする確率、しない確率は、以下のクエリで確認できる。predicted_label_probs.prob に、それぞれのラベルの確率が格納されている。

SELECT
  *
FROM
  ML.PREDICT(MODEL  `bqml_tutorial.model001_nextSessionTransaction`,
 (
  SELECT
  #次のセッションが発生したセッションに絞る
    *
  FROM (
    SELECT
      #今回の目的変数。次のセッションでtransactionするかどうか。
      LEAD(IF(totals.transactions >0,1,0 )) OVER(partition by fullVisitorId  ORDER BY visitStartTime)  AS label,
      #以下が組成。このセッションでの行動から作成する。
      IF(totals.transactions IS NULL, 0, 1) AS transaction,
      IFNULL(device.operatingSystem, "") AS os,
      device.isMobile AS is_mobile,
      IFNULL(geoNetwork.country, "") AS country,
      IFNULL(totals.pageviews, 0) AS pageviews,
      IFNULL(totals.bounces, 0) AS bounces,
      IFNULL(totals.timeOnSite , 0) AS timeOnSite,
      IFNULL(totals.newVisits  , 0) AS newVisits,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Quickview Click") AS is_qc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Add to Cart") AS is_ac,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Product Click") AS is_pc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Remove from Cart") AS is_rc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Promotion Click") AS is_prmc
    FROM
      `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
    WHERE
      _TABLE_SUFFIX BETWEEN '20170701' AND '20170801'
  )
  WHERE label IS NOT NULL
)
)

参考
The ML.PREDICT Function  |  BigQuery  |  Google Cloud

ステップ5: ML.ROC_CURVE 関数を実行し、閾値を調整する

ステップ4でroc_aucが0.822であることを確認した。これはこのモデルを使えば、次のセッションでtransactionが発生しそうなセッションを効率よく抽出できることを意味する。

ML.ROC_CURVE関数を実行し、実行結果をデータポータルで調べてみる。
The ML.ROC_CURVE Function  |  BigQuery  |  Google Cloud

#standardSQL
SELECT
  *
FROM
  ML.ROC_CURVE (MODEL  `bqml_tutorial.model001_nextSessionTransaction`,
 (
  SELECT
  #次のセッションが発生したセッションに絞る
    *
  FROM (
    SELECT
      #今回の目的変数。次のセッションでtransactionするかどうか。
      LEAD(IF(totals.transactions >0,1,0 )) OVER(partition by fullVisitorId  ORDER BY visitStartTime)  AS label,
      #以下が組成。このセッションでの行動から作成する。
      IF(totals.transactions IS NULL, 0, 1) AS transaction,
      IFNULL(device.operatingSystem, "") AS os,
      device.isMobile AS is_mobile,
      IFNULL(geoNetwork.country, "") AS country,
      IFNULL(totals.pageviews, 0) AS pageviews,
      IFNULL(totals.bounces, 0) AS bounces,
      IFNULL(totals.timeOnSite , 0) AS timeOnSite,
      IFNULL(totals.newVisits  , 0) AS newVisits,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Quickview Click") AS is_qc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Add to Cart") AS is_ac,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Product Click") AS is_pc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Remove from Cart") AS is_rc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Promotion Click") AS is_prmc
    FROM
      `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
    WHERE
      _TABLE_SUFFIX BETWEEN '20170701' AND '20170801'
  )
  WHERE label IS NOT NULL
)
)

例えば閾値を 0.03837951 とすると、次のセッションでtransactionするセッションの82%、transactionしないセッションの31%が抽出される。

ステップ6: 採用した閾値で予測をし直す

ML.PREDICTを使って、再度予測をする。
なお、2018-12-31現在日本語ページには書いていないが、ML.PREDICT関数には引数として閾値(threshold)を渡すことができる。また、閾値(threshold)のデフォルト値は0.5となっているらしい。
The ML.PREDICT Function  |  BigQuery  |  Google Cloud

#standardSQL
SELECT
  *
FROM
  ML.PREDICT (MODEL  `bqml_tutorial.model001_nextSessionTransaction`,
 (
  SELECT
  #次のセッションが発生したセッションに絞る
    *
  FROM (
    SELECT
      #今回の目的変数。次のセッションでtransactionするかどうか。
      LEAD(IF(totals.transactions >0,1,0 )) OVER(partition by fullVisitorId  ORDER BY visitStartTime)  AS label,
      #以下が組成。このセッションでの行動から作成する。
      IF(totals.transactions IS NULL, 0, 1) AS transaction,
      IFNULL(device.operatingSystem, "") AS os,
      device.isMobile AS is_mobile,
      IFNULL(geoNetwork.country, "") AS country,
      IFNULL(totals.pageviews, 0) AS pageviews,
      IFNULL(totals.bounces, 0) AS bounces,
      IFNULL(totals.timeOnSite , 0) AS timeOnSite,
      IFNULL(totals.newVisits  , 0) AS newVisits,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Quickview Click") AS is_qc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Add to Cart") AS is_ac,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Product Click") AS is_pc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Remove from Cart") AS is_rc,
      EXISTS(SELECT h.eventInfo.eventAction FROM UNNEST(ga.hits) as h WHERE h.eventInfo.eventAction = "Promotion Click") AS is_prmc
    FROM
      `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
    WHERE
      _TABLE_SUFFIX BETWEEN '20170701' AND '20170801'
  )
  WHERE label IS NOT NULL
)
, STRUCT(0.03837951) #先程採用した閾値をML.EVALUATEの引数として渡す
)

以上です。

UNNESTせずに特定のページビューやイベントが発生したセッションのみ抽出する

概要

特定の要素を持つ配列全体を、EXISTS句を使って抽出します。
BigQuery Exportのデータなど、構造化されたBigQueryのデータを処理する際、元の構造を保ったまま特定の条件にあった行だけを抽出したいことがあるかと思います。

  • 特定のページビューが発生したセッションの直帰率を調べたい
  • 特定のイベントが発生したセッションのコンバージョン率を調べたい

今回はGoogleAnalyticsのサンプルデータを使用して書いていきます。
support.google.com

/google redesign/apparel/mens/mens t shirtsを閲覧したセッションのみ抽出する

#standardSQL
  WITH base AS (
  SELECT
    *
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
  WHERE
    _TABLE_SUFFIX >= "20170701"
    #以下が絞り込みを行っている部分です。
    AND EXISTS (
    SELECT
      h.page.pagePath
    FROM
      UNNEST(ga.hits) AS h
    WHERE
      h.type = "PAGE"
      AND h.page.pagePath = "/google redesign/apparel/mens/mens t shirts" ) )
SELECT
  *
FROM
  base

確認のため、サブクエリbaseに対して、fullvisitorId,visitId,hitNumber順に、hits.page.pagePath を見てみましょう。

  #StandardSQL
  WITH base AS (
  SELECT
    *
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
  WHERE
    _TABLE_SUFFIX >= "20170701"
    #以下が絞り込みを行っている部分です。
    AND EXISTS (
    SELECT
      h.page.pagePath
    FROM
      UNNEST(ga.hits) AS h
    WHERE
      h.type = "PAGE"
      AND h.page.pagePath = "/google redesign/apparel/mens/mens t shirts" ) )
SELECT
  fullVisitorId,
  visitId,
  h.hitNumber,
  h.page.pagePath
FROM
  base AS ga,
  ga.hits AS h
WHERE
  h.type = "PAGE"
ORDER BY
  1,
  2,
  3 ASC

確かに、今回絞り込みたかったpagePathを含むセッションがそのまま残っているようです。
f:id:sem-aa-bq:20181222165914p:plain


これをもとに、 `/google redesign/apparel/mens/mens t shirts`を閲覧したセッションの日毎の直帰率を調べるには、以下のように書けばよいです。

WITH
  base AS (
  SELECT
    *
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
  WHERE
    _TABLE_SUFFIX >= "20170701"
    #以下が絞り込みを行っている部分です。
    AND EXISTS (
    SELECT
      h.page.pagePath
    FROM
      UNNEST(ga.hits) AS h
    WHERE
      h.type = "PAGE"
      AND h.page.pagePath = "/google redesign/apparel/mens/mens t shirts" ) )
SELECT
  date,
  COUNT(*) AS session,
  SUM(totals.bounces) AS bounce_cnt,
  SAFE_DIVIDE(SUM(totals.bounces),COUNT(*)) AS bounce_rate,
  FORMAT("%3.2f %%",SAFE_DIVIDE(SUM(totals.bounces),COUNT(*) ) * 100  ) AS bounce_rate_formatted
FROM
  base
GROUP BY
  1
ORDER BY
  1 ASC

同様に、eventAction Add to Cartイベントが発生したセッションを抽出してみましょう。
以下のように書けます。

#standardSQL
  WITH base AS (
  SELECT
    *
  FROM
    `bigquery-public-data.google_analytics_sample.ga_sessions_*` AS ga
  WHERE
    _TABLE_SUFFIX >= "20170701"
    #以下が絞り込みを行っている部分です。
    AND EXISTS (
    SELECT
      h.page.pagePath
    FROM
      UNNEST(ga.hits) AS h
    WHERE
      h.type = "EVENT"
      AND h.eventInfo.eventAction = "Add to Cart" ))
SELECT
  *
FROM
  base

以上です。

参考
標準 SQL での配列の操作  |  BigQuery  |  Google Cloud

BigQueryで全角英数を半角英数に変換する

以下の流れで処理しています。

  1. TO_CODE_POINTS 関数を使って文字列をコードポイントの配列に変換します。
  2. コードポイント配列をUNNESTします。
  3. コードポイントが全角英数にマッチする場合、対応する半角英数のコードポイントになるようにずらします。
  4. コードポイントを配列にまとめ直し、CODE_POINTS_TO_STRING 関数を使って配列を文字列に戻します。

https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators#to_code_points

クエリ

#StandardSQL
WITH
  sample AS (
  #サンプルデータを用意
  SELECT
    dat
  FROM
    UNNEST( ["ABCDEFGHIJKLMNOPQRSTUVWXYZ","ABCDEFGH1234","USA","29800円"] ) AS dat )

SELECT
  dat,
  CODE_POINTS_TO_STRING( ARRAY(
    SELECT
      CASE 
 # 全角大文字を半角大文字に変換
      WHEN  code_point_arr BETWEEN 65313 AND 65338 THEN code_point_arr-65248
 # 全角小文字を半角小文字に変換
      WHEN  code_point_arr BETWEEN 65345 AND 65370 THEN code_point_arr-65248
 # 全角数字を半角数字に変換
      WHEN  code_point_arr BETWEEN 65296 AND 65305 THEN code_point_arr-65248
       ELSE  code_point_arr END
    FROM
    #
    # TO_CODE_POINTS の引数に、半角変換を行いたい列を指定する
    #
      UNNEST( TO_CODE_POINTS(dat) )AS code_point_arr )) as formatted_dat
FROM
  sample

以上です。

BigQueryでサイト内検索データや検索語句レポートから、よく使われる単語を抽出する

想定する場面

BigQueryに格納されている、サイト内検索のデータや、Adwordsの検索クエリレポートなどから、よく使われる単語を抽出します。
例えば、
「Big Query run」
「j Query run」
という2行のデータがある時

word count
Query 2
run 2
j 1
Big 1

のように、各単語に分解してその出現回数を数えます。

やり方

今回は、split関数を使い、単語を空白で分割してarrayに変換し、unnestした後に数え上げます。
以下のデータを用意します。

searchTerm click
j query 4
j リーグ 6
adwords 8
adwords blog 10
google analytics 3
sql 4

サブクエリを使用してデータを用意するやり方はこちらで解説しています。
sem-aa-bq.hatenablog.com


完成したクエリはこちら

WITH temp_table AS (
  SELECT
    *
  FROM 
  UNNEST(
    (
      SELECT
      ARRAY<STRUCT<searchTerm STRING,click INT64>>[
        ("google big query",2),
        ("j query",4),
        ("j リーグ",6),
        ("adwords",8),
        ("adwords blog",10),
        ("google analytics",3),
        ("sql",4)
      ] AS sample_data_arr
    )
  )
),

splitted AS (
#このサブクエリで単語分割
SELECT
  split(searchTerm," ") as splitted_st_arr
FROM
  temp_table )
#出現回数で集計
SELECT
  splitted_st,
  COUNT(*) AS cnt
FROM
  splitted CROSS JOIN UNNEST(splitted.splitted_st_arr) AS splitted_st
GROUP BY 1
ORDER BY 2 desc

なお、上記のクエリで使用したデータで、click数で集計するということもできます。
例えば、adwordsという単語は、"adwords"というsearchTermで8回、"adwords blog"で10回clickが発生しているので、集計値として18を出したいです。
これは広告屋検索の部分一致除外を検討する際のデータとして役にたつかと思います。

click数で集計するsqlは下記の通りです。

WITH temp_table AS (
  SELECT
    *
  FROM 
  UNNEST(
    (
      SELECT
      ARRAY<STRUCT<searchTerm STRING,click INT64>>[
        ("google big query",2),
        ("j query",4),
        ("j リーグ",6),
        ("adwords",8),
        ("adwords blog",10),
        ("google analytics",3),
        ("sql",4)
      ] AS sample_data_arr
    )
  )
),

splitted AS (
#このサブクエリで単語分割
SELECT
  split(searchTerm," ") as splitted_st_arr,
  *
FROM
  temp_table )
#click回数で集計
SELECT
  splitted_st,
  SUM(click) AS click
FROM
  splitted CROSS JOIN UNNEST(splitted.splitted_st_arr) AS splitted_st
GROUP BY 1
ORDER BY 2 desc

以上です。