【SQL】ウィンドウ関数で遊んでみる

こんにちは、knsk765 です。データの分析するときってウィンドウ関数は欠かせないですよね。
はじめてウィンドウ関数を知ったときは衝撃でした。これまで強引な集計処理をゴリゴリ書いてどれだけの時間を無駄にしつづけたのかと。

ざっくりいうと GROUP BY を使った集約関数のように結果をまとめてしまわずに、元の行を保持したまま集計列を追加できるのがウィンドウ関数です。
Excel の表に関数を入れた集計列を追加するのがイメージとしては近いかなと思います。

ウィンドウ関数のちゃんとした説明はこちら(PostgreSQL 文書)。

www.postgresql.jp

目次

準備

実行環境

PostgreSQL 14.5

テーブル

まずはサンプルデータを用意します。

CREATE TABLE public.scores (
  id int4 NOT NULL,
  acquisition_date date NOT NULL,
  user_name varchar NOT NULL,
  score int4 NOT NULL,
  CONSTRAINT scores_pk PRIMARY KEY (id)
);

INSERT INTO scores VALUES
(1, '2024-03-14', '松崎しげろ', 87),
(2, '2024-03-14', '竹内刀', 60),
(3, '2024-03-14', 'ア三バ', 0),
(4, '2024-03-14', '空条Q太郎', 14),
(5, '2024-03-15', '松崎しげろ', 96),
(6, '2024-03-15', '竹内刀', 25),
(7, '2024-03-15', 'ア三バ', 43),
(8, '2024-03-15', '空条Q太郎', 8),
(9, '2024-03-16', '松崎しげろ', 32),
(10, '2024-03-16', '竹内刀', 63),
(11, '2024-03-16', 'ア三バ', 19),
(12, '2024-03-16', '空条Q太郎', 68);

こんなデータです。なんかの点数を取得した日々の履歴的なイメージです。 なんの点数かは謎だし登場人物も謎。

id acquisition_date user_name score
1 2024-03-14 松崎しげろ 87
2 2024-03-14 竹内刀 60
3 2024-03-14 ア三バ 0
4 2024-03-14 空条Q太郎 14
5 2024-03-15 松崎しげろ 96
6 2024-03-15 竹内刀 25
7 2024-03-15 ア三バ 43
8 2024-03-15 空条Q太郎 8
9 2024-03-16 松崎しげろ 32
10 2024-03-16 竹内刀 63
11 2024-03-16 ア三バ 19
12 2024-03-16 空条Q太郎 68

その日一番高い得点を調べろ

GROUP BY と MAX 関数

まずはウィンドウ関数ではなく普通の集約関数で。

SELECT
  acquisition_date, MAX(score) max_score
FROM scores
GROUP BY acquisition_date
ORDER BY acquisition_date;
acquisition_date max_score
2024-03-14 87
2024-03-15 96
2024-03-16 68

これは簡単ですね。
じゃあ、その最高得点をとったのは誰なのか知りたくないですか?知りたいですよね?
こんな結果がほしいわけです。

acquisition_date score user_name
2024-03-14 87 松崎しげろ
2024-03-15 96 松崎しげろ
2024-03-16 68 空条Q太郎

では答えから。

WITH cte AS (
  SELECT
    ROW_NUMBER()
      OVER (PARTITION BY acquisition_date ORDER BY score DESC) rownum,
    acquisition_date, score, user_name
  FROM
    scores
)
SELECT acquisition_date, score, user_name
FROM cte
WHERE rownum = 1
ORDER BY acquisition_date;

ここで注目すべきは ROW_NUMBER。 これがウィンドウ関数です。
なにをしているのか、分解して見てみましょう。

ROW_NUMBER 関数

SELECT
  ROW_NUMBER()
    OVER (PARTITION BY acquisition_date ORDER BY score DESC) rownum,
  acquisition_date, score, user_name
FROM
  scores;

実行結果。ROW_NUMBER 関数で 1 から 4 の番号が振られていますね。

rownum acquisition_date score user_name
1 2024-03-14 87 松崎しげろ
2 2024-03-14 60 竹内刀
3 2024-03-14 14 空条Q太郎
4 2024-03-14 0 ア三バ
1 2024-03-15 96 松崎しげろ
2 2024-03-15 43 ア三バ
3 2024-03-15 25 竹内刀
4 2024-03-15 8 空条Q太郎
1 2024-03-16 68 空条Q太郎
2 2024-03-16 63 竹内刀
3 2024-03-16 32 松崎しげろ
4 2024-03-16 19 ア三バ

ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)
ROW_NUMBER は SELECT で取得した結果に連番を振る関数です。
振り方のルールは OVER の中の PARTITION BYORDER BY で決まります。

PARTITION BY acquisition_date
GROUP BY のようなもので、同じ acquisition_date の値でレコードセットをグルーピングします。

ORDER BY score DESC
グルーピングされた acquisition_date の中で score 降順に並べます。

つまり

  • 日付ごとにグルーピングした中で
  • 点数の高い順で 1 から順番に番号を振る

こうすると日付ごとに rownum = 1 のレコードが最も高い点数となるので、あとは絞り込むだけ。

最高点と最低点出したやつ誰だ

こんなふうに日ごとに最高得点者と最低得点者を出すこともできます。

acquisition_date score user_name
2024-03-14 87 松崎しげろ
2024-03-14 0 ア三バ
2024-03-15 96 松崎しげろ
2024-03-15 8 空条Q太郎
2024-03-16 68 空条Q太郎
2024-03-16 19 ア三バ
WITH cte AS (
  SELECT
    ROW_NUMBER() OVER (
      PARTITION BY acquisition_date ORDER BY score asc)  rownum_asc,
    ROW_NUMBER() OVER (
      PARTITION BY acquisition_date ORDER BY score desc) rownum_desc,
    acquisition_date, user_name, score
  FROM
    scores
)
SELECT acquisition_date, score, user_name
FROM cte
WHERE rownum_asc = 1 or rownum_desc = 1
ORDER BY acquisition_date, rownum_desc, rownum_asc;

集計もしたい

SUM で累計推移

ウィンドウ関数として、SUM なども利用できます。
人ごとの日別累計推移を出してみましょう。

WITH cte AS (
  SELECT 
    SUM(score) OVER (
      PARTITION BY user_name ORDER BY acquisition_date) score_sum,
    acquisition_date, user_name, score
  FROM
    scores
)
SELECT user_name, acquisition_date, score, score_sum
FROM cte
ORDER BY user_name, acquisition_date;
user_name acquisition_date score score_sum
ア三バ 2024-03-14 0 0
ア三バ 2024-03-15 43 43
ア三バ 2024-03-16 19 62
松崎しげろ 2024-03-14 87 87
松崎しげろ 2024-03-15 96 183
松崎しげろ 2024-03-16 32 215
空条Q太郎 2024-03-14 14 14
空条Q太郎 2024-03-15 8 22
空条Q太郎 2024-03-16 68 90
竹内刀 2024-03-14 60 60
竹内刀 2024-03-15 25 85
竹内刀 2024-03-16 63 148

その日の平均値より高いのかい?低いのかい?

どっちなんだい?

WITH cte AS (
  SELECT
    ROUND(
      AVG(score) OVER (PARTITION BY acquisition_date),
    1) score_avg,
    acquisition_date, user_name, score
  FROM
    scores
)
SELECT
  acquisition_date, score_avg, user_name, score,
  CASE
    WHEN score < score_avg THEN '😱'
    WHEN score > score_avg THEN '😍'
    ELSE '😐'
  END status
FROM cte
ORDER BY acquisition_date, user_name;

CASE も使って平均との比較をわかりやすく表示してみました。

acquisition_date score_avg user_name score status
2024-03-14 40.3 ア三バ 0 😱
2024-03-14 40.3 松崎しげろ 87 😍
2024-03-14 40.3 空条Q太郎 14 😱
2024-03-14 40.3 竹内刀 60 😍
2024-03-15 43.0 ア三バ 43 😐
2024-03-15 43.0 松崎しげろ 96 😍
2024-03-15 43.0 空条Q太郎 8 😱
2024-03-15 43.0 竹内刀 25 😱
2024-03-16 45.5 ア三バ 19 😱
2024-03-16 45.5 松崎しげろ 32 😱
2024-03-16 45.5 空条Q太郎 68 😍
2024-03-16 45.5 竹内刀 63 😍

昨日と比べてどう?

SQL で取得したレコード同士、前後の行で比較して差分を出したりしたいことってないですか?
Excel なら簡単なのに... と諦めるのは早い。できます。
では前日の自分の得点との差分を出してみましょう。

WITH cte AS (
  SELECT
    LAG(score, 1) OVER (
      PARTITION BY user_name ORDER BY acquisition_date) score_previous,
    acquisition_date, user_name, score
  FROM
    scores
)
SELECT
  user_name, acquisition_date, score, score_previous,
  score - score_previous AS score_diff
FROM cte
ORDER BY user_name, acquisition_date;
user_name acquisition_date score score_previous score_diff
ア三バ 2024-03-14 0 (NULL) (NULL)
ア三バ 2024-03-15 43 0 43
ア三バ 2024-03-16 19 43 -24
松崎しげろ 2024-03-14 87 (NULL) (NULL)
松崎しげろ 2024-03-15 96 87 9
松崎しげろ 2024-03-16 32 96 -64
空条Q太郎 2024-03-14 14 (NULL) (NULL)
空条Q太郎 2024-03-15 8 14 -6
空条Q太郎 2024-03-16 68 8 60
竹内刀 2024-03-14 60 (NULL) (NULL)
竹内刀 2024-03-15 25 60 -35
竹内刀 2024-03-16 63 25 38

score_previous 列を見ると、同じユーザーの 1行前の score が表示されています。
LAG 関数を使うと指定した行数分前のデータを持って来れます。

LAG(対象列, さかのぼる行数) OVER (PARTITION BY ... ORDER BY ...)
今回は LAG の第二引数に 1 を指定したので 1行前のデータを持ってきたわけです。 逆にあとの行を持ってきたいときは LEAD 関数を使います。
などと得意げに語っていますが、今さっきまで LAG も LEAD も知りませんでした...

おまけ

blog.ingage.jp

前回の記事の「おまけ」で書いた下の表、今回ご紹介したウィンドウ関数を使えば実現できます。

id tree_nmae
1 +- 管理本部
101 | +- 総務人事部
10101 | | +- 総務課
10102 | | `- 人事課
102 | +- 財務部
10201 | | `- 経理課
103 | `- 情報システム室
2 +- 営業本部
201 | +- 営業企画部
20101 | | `- 営業企画課
202 | `- 営業部
20201 | +- 大阪営業所
20202 | +- 東京営業所
20203 | `- 名古屋営業所
3 `- 開発本部
301 +- 商品開発部
30101 | +- 1課
30102 | `- 2課
3010201 | +- 第一小隊
3010202 | `- 第二小隊
302 `- 空手部

「気になる答えはまた次の機会に!」と書きましたが、また引っ張ります。
いや、早く答え合わせさせろよ!という方は下の採用ページからぜひご応募を!