タグ「SQL」が付けられているもの

MySQLのウィンドウ関数を使ってみる

筆者は今、会社でビッグデータを扱うことがあり、周囲でSQLがよく使われるようになってきた。自分では単純なクエリしか書かないが、時々他の人が書いたSQLを読む機会がある。他の人が書いたSQLも大体基本的な構文しか使ってないので、頑張れば読める。その中で唯一、よくわかってないままだったのが、ウィンドウ関数である。

筆者はあまりSQLに深入りする気が無く、こういう複雑な文法は覚える気が無いのだが、ウィンドウ関数だけは業務上避けて通れず、筆者自身も見よう見まねで書くことがあるので、この正月休みに一度きちんと理解しておこうと思った。

会社のSQLサーバーはMySQLではないが、幸い自宅のRaspberry Pi (OS buster)にインストールされているMySQL(MariaDB 10.3)は同様のウィンドウ関数をサポートしているので、これを使って勉強しながら、何かやってみることにした。

tsidvalue
2023-01-01 00:00:00ID_00110
2023-01-01 00:00:00ID_0020
2023-01-01 00:00:00ID_003-10
2023-01-01 00:05:00ID_0019
2023-01-01 00:05:00ID_002-4
2023-01-01 00:05:00ID_003-9
2023-01-01 00:10:00ID_0017
2023-01-01 00:10:00ID_002-7
2023-01-01 00:10:00ID_003-7
2023-01-01 00:15:00ID_0014
2023-01-01 00:15:00ID_002-9
2023-01-01 00:15:00ID_003-4
2023-01-01 00:20:00ID_0011
2023-01-01 00:20:00ID_002-10
2023-01-01 00:20:00ID_003-1
.........

こういう時系列のデータがある時に、id毎に、直前1時間の分散が一定以上であるデータが連続する区間を抽出したいとする(テストデータ作成に用いたコードは後述)。
大体5分毎になっているが、欠損があり、12行前が1時間前とは限らないとする。

まず、各行の1時間前までの分散を計算する。 id毎に計算するのでPARTITION BY id、1時間前までのフレームなのでMySQLならORDER BY ts RANGE INTERVAL 1 HOUR PRECEDINGRANGE以降はRANGE BETWEEN INTERVAL 1 HOUR PRECEDING AND CURRENT ROWでも同じ)だがあいにくMariaDB 10.3では日付型のRANGEが未サポートのようなのでORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDINGとする。

SELECT
  ts,
  id,
  value,
  VARIANCE(value) OVER (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING) AS rollvar
FROM testdata;
出力
tsidvaluerollvar
2023-01-01 00:00:00ID_001100.0000
2023-01-01 00:05:00ID_00190.2500
2023-01-01 00:10:00ID_00171.5556
2023-01-01 00:15:00ID_00145.2500
2023-01-01 00:20:00ID_001110.9600
2023-01-01 00:25:00ID_001-320.8889
2023-01-01 00:30:00ID_001-631.8367
2023-01-01 00:35:00ID_001-841.4375
2023-01-01 00:40:00ID_001-1050.4691
............
2023-01-01 07:35:00ID_003-914.0764
2023-01-01 07:40:00ID_003-911.9097
2023-01-01 07:45:00ID_003-1010.9167
2023-01-01 07:50:00ID_003-109.3889
2023-01-01 07:55:00ID_003-107.5764
261 rows in set

各idの最初の方は1時間分のデータが無い分散になってしまうのと、フレームが1時間の両端のタイムスタンプを含むので1時間5分の分散のようになってしまうが、今回は練習なのでこれで良しとする。
ついでに、WINDOW句(名前付きウィンドウ)を使って、同じフレームの1時間の平均も求めるようにする。

SELECT
  ts,
  id,
  value,
  AVG(value) OVER w AS rollmean,
  VARIANCE(value) OVER w AS rollvar
FROM testdata
WINDOW w AS (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING);
出力
tsidvaluerollmeanrollvar
2023-01-01 00:00:00ID_0011010.00000.0000
2023-01-01 00:05:00ID_00199.50000.2500
2023-01-01 00:10:00ID_00178.66671.5556
2023-01-01 00:15:00ID_00147.50005.2500
2023-01-01 00:20:00ID_00116.200010.9600
2023-01-01 00:25:00ID_001-34.666720.8889
2023-01-01 00:30:00ID_001-63.142931.8367
2023-01-01 00:35:00ID_001-81.750041.4375
2023-01-01 00:40:00ID_001-100.444450.4691
2023-01-01 00:50:00ID_001-9-0.500053.4500
...............
2023-01-01 07:35:00ID_003-9-3.583314.0764
2023-01-01 07:40:00ID_003-9-4.583311.9097
2023-01-01 07:45:00ID_003-10-5.500010.9167
2023-01-01 07:50:00ID_003-10-6.33339.3889
2023-01-01 07:55:00ID_003-10-7.08337.5764
261 rows in set

次に、一例として1時間の分散が40以上の行を1とするフラグの列を付ける。 上のクエリをサブクエリとして、その結果を使えば簡単である。

WITH t1 AS (
  SELECT
    ts,
    id,
    value,
    AVG(value) OVER w AS rollmean,
    VARIANCE(value) OVER w AS rollvar
  FROM testdata
  WINDOW w AS (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING)
)
SELECT *, rollvar >= 40 AS flag FROM t1;
出力
tsidvaluerollmeanrollvarflag
2023-01-01 00:00:00ID_0011010.00000.00000
2023-01-01 00:05:00ID_00199.50000.25000
2023-01-01 00:10:00ID_00178.66671.55560
2023-01-01 00:15:00ID_00147.50005.25000
2023-01-01 00:20:00ID_00116.200010.96000
2023-01-01 00:25:00ID_001-34.666720.88890
2023-01-01 00:30:00ID_001-63.142931.83670
2023-01-01 00:35:00ID_001-81.750041.43751
2023-01-01 00:40:00ID_001-100.444450.46911
2023-01-01 00:50:00ID_001-9-0.500053.45001
2023-01-01 00:55:00ID_001-7-1.090952.08261
2023-01-01 01:00:00ID_001-5-1.416748.90971
2023-01-01 01:05:00ID_001-2-2.416737.07640
2023-01-01 01:10:00ID_0011-3.083326.74310
2023-01-01 01:15:00ID_0014-3.333322.38890
..................

次に、上のフラグが変化する部分=フラグの値が同じ部分の先頭行を1とする列を加える。
ウィンドウ関数のLAG()を使って1つ前のデータを参照すれば容易である。
但し、LAG()は各idの1つ前が無い先頭のデータについてはNULLになり、その為flag <> lag(flag)もNULLになってしまうので、IFNULL(x, 1)で1にする。

WITH t1 AS (
  SELECT
    ts,
    id,
    value,
    AVG(value) OVER w AS rollmean,
    VARIANCE(value) OVER w AS rollvar
  FROM testdata
  WINDOW w AS (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING)
), t2 AS (
  SELECT *, rollvar >= 40 AS flag FROM t1
)
SELECT *, IFNULL(flag <> lag(flag) OVER (PARTITION BY id ORDER BY ts), 1) AS flagchg FROM t2;
出力
tsidvaluerollmeanrollvarflagflagchg
2023-01-01 00:00:00ID_0011010.00000.000001
2023-01-01 00:05:00ID_00199.50000.250000
2023-01-01 00:10:00ID_00178.66671.555600
2023-01-01 00:15:00ID_00147.50005.250000
2023-01-01 00:20:00ID_00116.200010.960000
2023-01-01 00:25:00ID_001-34.666720.888900
2023-01-01 00:30:00ID_001-63.142931.836700
2023-01-01 00:35:00ID_001-81.750041.437511
2023-01-01 00:40:00ID_001-100.444450.469110
2023-01-01 00:50:00ID_001-9-0.500053.450010
2023-01-01 00:55:00ID_001-7-1.090952.082610
2023-01-01 01:00:00ID_001-5-1.416748.909710
2023-01-01 01:05:00ID_001-2-2.416737.076401
2023-01-01 01:10:00ID_0011-3.083326.743100
2023-01-01 01:15:00ID_0014-3.333322.388900
.....................

IFNULL(flag <> lag(flag) OVER ... , 1)の部分は、MySQL独自の<=>(NULL-safe equal)という演算子(他のSQL処理系にも大体等価なものがありそう)を使ってNOT flag <=> lag(flag) OVER ...とも書けたが、可読性に難ありと思った。

次に、フラグが変化した回数を累積し、フラグの値が同じ部分のグループ番号とする。
これもウィンドウ関数を使えば容易である。
OVER句にROWSやRANGE無しでORDER BY xxxだけを付けると、ORDER BY xxx RANGE UNBOUNDED PRECEDINGと同じ、つまりその行以前の全行がフレームになる。なお、ORDER BY xxxも付けないと、ORDER BY xxx RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGと同じ、つまり全行がフレームになる。

WITH t1 AS (
  SELECT
    ts,
    id,
    value,
    AVG(value) OVER w AS rollmean,
    VARIANCE(value) OVER w AS rollvar
  FROM testdata
  WINDOW w AS (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING)
), t2 AS (
  SELECT *, rollvar >= 40 AS flag FROM t1
), t3 AS (
  SELECT *, IFNULL(flag <> lag(flag) OVER (PARTITION BY id ORDER BY ts), 1) AS flagchg FROM t2
)
SELECT *, SUM(flagchg) OVER (PARTITION BY id ORDER BY ts) AS groupnum FROM t3;
出力
tsidvaluerollmeanrollvarflagflagchggroupnum
2023-01-01 00:00:00ID_0011010.00000.0000011
2023-01-01 00:05:00ID_00199.50000.2500001
2023-01-01 00:10:00ID_00178.66671.5556001
2023-01-01 00:15:00ID_00147.50005.2500001
2023-01-01 00:20:00ID_00116.200010.9600001
2023-01-01 00:25:00ID_001-34.666720.8889001
2023-01-01 00:30:00ID_001-63.142931.8367001
2023-01-01 00:35:00ID_001-81.750041.4375112
2023-01-01 00:40:00ID_001-100.444450.4691102
2023-01-01 00:50:00ID_001-9-0.500053.4500102
2023-01-01 00:55:00ID_001-7-1.090952.0826102
2023-01-01 01:00:00ID_001-5-1.416748.9097102
2023-01-01 01:05:00ID_001-2-2.416737.0764013
2023-01-01 01:10:00ID_0011-3.083326.7431003
2023-01-01 01:15:00ID_0014-3.333322.3889003
........................
2023-01-01 07:45:00ID_003-10-5.500010.9167009
2023-01-01 07:50:00ID_003-10-6.33339.3889009
2023-01-01 07:55:00ID_003-10-7.08337.5764009
261 rows in set

最後に、上のグループ番号でGROUP BYしてflag=1の行のみ出力する。

WITH t1 AS (
  SELECT
    ts,
    id,
    value,
    AVG(value) OVER w AS rollmean,
    VARIANCE(value) OVER w AS rollvar
  FROM testdata
  WINDOW w AS (PARTITION BY id ORDER BY UNIX_TIMESTAMP(ts) RANGE 3600 PRECEDING)
), t2 AS (
  SELECT *, rollvar >= 40 AS flag FROM t1
), t3 AS (
  SELECT *, IFNULL(flag <> lag(flag) OVER (PARTITION BY id ORDER BY ts), 1) AS flagchg FROM t2
), t4 AS (
  SELECT *, SUM(flagchg) OVER (PARTITION BY id ORDER BY ts) AS groupnum FROM t3
)
SELECT
  id,
  MIN(ts) AS 'from(ts)',
  TIMESTAMPDIFF(MINUTE, MIN(ts), MAX(ts)) AS duration,
  MIN(rollvar),
  MAX(rollvar),
  flag
FROM t4
GROUP BY id, groupnum
HAVING flag = 1;
出力
idfrom(ts)durationMIN(rollvar)MAX(rollvar)flag
ID_0012023-01-01 00:35:002541.437553.45001
ID_0012023-01-01 01:30:002042.243150.42981
ID_0012023-01-01 02:35:001045.472249.91671
ID_0012023-01-01 03:40:001041.076444.63891
ID_0022023-01-01 01:00:002541.388966.05561
ID_0022023-01-01 01:55:002044.472260.30561
ID_0022023-01-01 03:05:00043.833343.83331
ID_0032023-01-01 00:40:002046.750056.81001
ID_0032023-01-01 01:30:002041.140557.68751
ID_0032023-01-01 02:30:001546.743153.47221
ID_0032023-01-01 03:40:001040.576443.08331
11 rows in set

[MySQL] 2時間毎にGROUP BYする方法

先日、タイムスタンプを含むデータをSQLで2時間毎に集計したいことがあった。

idtsvalue
ID_0012022-04-01 00:00:011
ID_0012022-04-01 00:05:012
ID_0012022-04-01 00:10:013
ID_0012022-04-01 00:15:014
ID_0012022-04-01 00:20:015
.........
ID_0022022-04-01 00:00:022
ID_0022022-04-01 00:05:023
ID_0022022-04-01 00:10:024
ID_0022022-04-01 00:15:025
ID_0022022-04-01 00:20:026
.........

こういうデータがあり、GROUP BYでid毎、2時間毎(00:00:00-, 02:00:00-, 04:00:00-, ...)に集計したいとする。
TIMESTAMP型のデータからHOUR()で時分秒の時だけ取り出せるらしいので、次のようにしてみたら、望み通りの集計はできたのだが、結果のタイムスタンプが2時間毎に切り下げられない(時分秒の時が偶数かつ分秒が00:00にならない)のが不満だった。

●コード
/* This doesn't return round timestamp */
SELECT id, ts, avg(value)
FROM testdata
GROUP BY id, DATE(ts), HOUR(ts) DIV 2;
●実行結果
idtsavg(value)
ID_0012022-04-01 00:00:0112.5000
ID_0012022-04-01 02:00:0136.5000
ID_0012022-04-01 04:00:0160.5000
ID_0012022-04-01 06:00:0184.5000
ID_0022022-04-01 00:00:0213.5000
ID_0022022-04-01 02:00:0237.5000
ID_0022022-04-01 04:00:0261.5000
ID_0022022-04-01 06:00:0285.5000
.........

Webで探すと、例えば30分毎のGROUP BYならUNIX_TIMESTAMPを30分毎に切り下げるという方法がよく知られているようである。

●コード
/* Group by 30 min */
SELECT
  id,
  FROM_UNIXTIME((UNIX_TIMESTAMP(ts) DIV (30*60)) * 30*60) AS ts_new,
  avg(value)
FROM testdata
GROUP BY id, ts_new;
●実行結果
idts_newavg(value)
ID_0012022-04-01 00:00:003.5000
ID_0012022-04-01 00:30:009.5000
ID_0012022-04-01 01:00:0015.5000
ID_0012022-04-01 01:30:0021.5000
ID_0012022-04-01 02:00:0027.5000
.........

しかし、同じ方法で2時間毎にすると、例えばMySQLのタイムゾーン設定がJST(日本時間)だと、時分秒の時に奇数が加えられてしまうので、うまくいかない。

●コード
/* This doesn't work well because of odd time zone +09:00 */
SELECT
  id,
  FROM_UNIXTIME((UNIX_TIMESTAMP(ts) DIV (2*60*60)) * 2*60*60) as ts_new,
  avg(value)
FROM testdata
GROUP BY id, ts_new;
●実行結果
idts_newavg(value)
ID_0012022-03-31 23:00:006.5000
ID_0012022-04-01 01:00:0024.5000
ID_0012022-04-01 03:00:0048.5000
ID_0012022-04-01 05:00:0072.5000
ID_0012022-04-01 07:00:0090.5000
ID_0022022-03-31 23:00:007.5000
ID_0022022-04-01 01:00:0025.5000
ID_0022022-04-01 03:00:0049.5000
ID_0022022-04-01 05:00:0073.5000
ID_0022022-04-01 07:00:0091.5000
.........

結局、元の案でタイムスタンプを2時間単位に切り下げたものに作り直すようにした。

●コード
SELECT
  id,
  DATE_ADD(DATE(ts),
  INTERVAL HOUR(ts) DIV 2 * 2 HOUR) as ts,
  avg(value)
FROM testdata
GROUP BY id, DATE(ts), HOUR(ts) DIV 2;
●実行結果
idtsavg(value)
ID_0012022-04-01 00:00:0012.5000
ID_0012022-04-01 02:00:0036.5000
ID_0012022-04-01 04:00:0060.5000
ID_0012022-04-01 06:00:0084.5000
ID_0022022-04-01 00:00:0013.5000
ID_0022022-04-01 02:00:0037.5000
ID_0022022-04-01 04:00:0061.5000
ID_0022022-04-01 06:00:0085.5000
.........

前回のエントリーで、書き方がわからなかったSQLというのは、そのエントリーの最後に書いたSQLの、"id"がややこしい式になる時のすっきりした書き方だった。

idid2COUNT(*)
NULLID_00110
ID_002NULL26
NULLID_00310
ID_004NULL3
.........

例えば、このように、IDが"id"と"id2"のどちらかに入っているとする。それに対し、前回のコードのサブクエリ以下の"id"をほぼ単純に"IFNULL(id, id2)"に書き換えたものが以下のものである。

●コード
/* original */
SELECT
  _id,
  COUNT(*) as data_num,
  AVG(count) AS mean,
  MAX(count) AS max
FROM (
  SELECT IFNULL(id, id2) AS _id, date, count
  FROM testdata
  WHERE IFNULL(id, id2) IN (
    SELECT *
    FROM (
      SELECT DISTINCT(IFNULL(id, id2))
      FROM testdata
      WHERE '2022-04-01' <= date AND date < '2022-04-10'
      LIMIT 3
    ) AS t1
  ) AND WHERE '2022-04-01' <= date AND date < '2022-05-01'
) AS t2
GROUP BY _id
ORDER BY _id;
●実行結果
_iddata_nummeanmax
ID_002264.923111
ID_006248.583315
ID_007257.920016

最も内側のSELECTでは2022-04-01〜2022-04-09のデータに含まれるIDの内3つを選択している。
その1つ外側のSELECT *は、前回のエントリーに記載したMySQLの制限対策である。
その1つ外側のSELECTでは、IDをその3つ、日付を2022-04-01〜2022-04-30に絞っている。
一番外側のSELECTでは、IDでGROUP BYして集計している。

このコードの何が不満かというと、IFNULL(id, id2)が3つあることと、やりたいことに対してサブクエリがネストし過ぎているように思えることである。
その為、個人的により良いと思える書き方を考えてみることにした。

まず、MySQLではSELECT節に書いた別名をGROUP BY節で使えるらしい(会社で使用している某DBMSでも使えた)ので、サブクエリを1つばらして"AS _id"とGROUP BYを一番外側のSELECT文で一緒にやるようにする。

●コード
/* v2 */
SELECT
  IFNULL(id, id2) AS _id,
  COUNT(*) as data_num,
  AVG(count) AS mean,
  MAX(count) AS max
FROM testdata
WHERE IFNULL(id, id2) IN (
  SELECT *
  FROM (
    SELECT DISTINCT(IFNULL(id, id2))
    FROM testdata
    WHERE '2022-04-01' <= date AND date < '2022-04-10'
    LIMIT 3
  ) AS t1
) AND '2022-04-01' <= date AND date < '2022-05-01'
GROUP BY _id
ORDER BY _id;
●実行結果 (同じなので省略)

次に、先にIDをDISTINCTにしてLIMIT 3するのとGROUP BYして最後にLIMIT 3するのは結果としては同じなので、内側のLIMITを外側に出す。するとDISTINCTとMySQLの制限対策のSELECT *は不要になるので、消す。
(全IDについてGROUP BYすることになりそうで、この時点では速度が気になるが)

●コード
/* v3 */
SELECT
  IFNULL(id, id2) AS _id,
  COUNT(*) as data_num,
  AVG(count) AS mean,
  MAX(count) AS max
FROM testdata
WHERE IFNULL(id, id2) IN (
  SELECT IFNULL(id, id2)
  FROM testdata
  WHERE '2022-04-01' <= date AND date < '2022-04-10'
) AND '2022-04-01' <= date AND date < '2022-05-01'
GROUP BY _id
ORDER BY _id
LIMIT 3;
●実行結果 (同じなので省略)

最後に、サブクエリのあるWHERE節の部分は、要するに2022-04-01〜2022-04-09のデータがあるグループを選択するものなので、GROUP BYのGROUPを絞るHAVING節に書き換える。

●コード
/* final */
SELECT
  IFNULL(id, id2) AS _id,
  COUNT(*) as data_num,
  AVG(count) AS mean,
  MAX(count) AS max
FROM testdata
WHERE '2022-04-01' <= date AND date < '2022-05-01'
GROUP BY _id
HAVING COUNT('2022-04-01' <= date AND date < '2022-04-10' OR NULL)
ORDER BY _id
LIMIT 3;
●実行結果 (同じなので省略)

HAVING節の所は個人的にはSUM('2022-04-01' <= date AND date < '2022-04-10') > 0の方がわかりやすいが、意味的にはSUMでなくANYが欲しい所であり、COUNTの方がANYの代用に相応しそうだし、COUNTの方がSQLっぽくて応用が利きそうな感じがするので、COUNTにした。

気になる処理時間であるが、テーブルの行数や結果の行数を増やして、RESET QUERY CACHE;でクエリキャッシュをクリアしてテーブルを作り直しながら複数回計測してみた結果、上の"original"と"v2"と"v3"のコードは大体同じ時間で、"final"のコードは半分くらいの時間だった。ばらつきが大きく、あまり信頼できない計測結果だったが、"final"のコードが最速なのは間違いなさそうだった。
Pandasのように、なるべく先に処理対象の行数を絞るようにする方が速くなるとは限らないようだ。

最近、会社でSQLを使い始めた。もらったサンプルコードにサブクエリがあり、それを書き換えながら実行することがある。これをもらうまで、サブクエリというものを知らなかった。

先月、自力でサブクエリを組み立てようとしたら、書き方がわからなかったことがあり、そのままGWに突入したので、GW中に考えてみようと思った。その為には、家で同じようなSQLが試せる環境が欲しい。
ふと、家のMySQLでもサブクエリが使えるのだろうか?と思って調べてみたらできるようで、ドキュメントに書かれている形の単純なものは動いた。そこで、今回悩んでいるクエリに近づけようと、サブクエリにLIMITを追加すると、エラーが出た。

●コード
SELECT *
FROM testdata
WHERE id IN (
  SELECT DISTINCT(id)
  FROM testdata
  WHERE '2022-04-01' <= date AND date < '2022-04-10'
  LIMIT 3
);
●実行結果
ERROR 1235 (42000): This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'

使用したMySQLはMySQL 5.5から派生したMariaDBのバージョン10.1である。 この制限はMariaDBのドキュメントにもMySQL 5.6のドキュメントにも書かれている(MariaDBのドキュメントの記載はわかりにくい)。
2発目で制限事項にヒットするとは、何とも引きが強い。

ネットで探し回ると、対策が2種類ほど見つかった。

●対策1:INでなくJOINを使って絞る

SELECT *
FROM testdata RIGHT JOIN (
  SELECT DISTINCT(id)
  FROM testdata
  WHERE '2022-04-01' <= date AND date < '2022-04-10'
  LIMIT 3
) AS t1 USING (id);
ネットではRIGHT JOINでなくJOIN、USINGでなくONとINを使う例が多く、こう書く例は見なかったが、筆者はSQLに慣れていないせいか、こう書く方がわかり易い。

●対策2:制限事項に当たらないサブクエリを挟む

SELECT *
FROM testdata
WHERE id IN (
  SELECT *
  FROM (
    SELECT DISTINCT(id)
    FROM testdata
    WHERE '2022-04-01' <= date AND date < '2022-04-10'
    LIMIT 3
  ) AS t1
);
こちらの対策は極めて簡単である。

サンプルデータの行数やサブクエリが返す行数を増やしたりして簡単に2つの対策の処理時間を比較してみたが、ほぼ差が見られなかった。
上の対策2が通るなら、元のコードは何故動かせないの?と思ってしまう。