グラフを動画化する

これは GMOペパボディレクター Advent Calendar 2022 17日の記事です。


上記のような感じで、時系列かつ複数の要素が同時に起こるデータを動画化してみたので、その作成過程を記載する。
(自分の手元では確認したのだけど、動画ちゃんと動いているかな…)

例えば、どのような検索キーワードが合わせて検索されることが増えているか、社内でどの部署同士の結びつきが強くなっているか、などの可視化に使えそうなイメージをしている。

ここではBigQueryの公開データセット theLook eCommerce を例として使用させていただいた。theLook eCommerce は架空の衣料品サイトにおける商品や注文等のデータが格納されているので、ある商品分野(「シャツ」「ジーンズ」など)の商品の併せ買いのされやすさの変化を可視化してみる。

データの前処理

使用したのは theLook eCommerce の productsテーブルとorder_items テーブルの2つ。

年月単位で、同時注文したカテゴリの組み合わせ別に注文数を集計する。

WITH duplicate_order_combinations AS(
  SELECT
    order_items.order_id,
    DATE(TIMESTAMP_TRUNC(order_items.created_at, MONTH)) AS created_at,
    CASE 
      WHEN products_destination.category IS NULL 
        THEN products.category
      WHEN products.category < products_destination.category 
        THEN CONCAT(products.category, "-", products_destination.category) 
      ELSE CONCAT(products_destination.category, "-", products.category) 
    END AS categories,
  FROM 
    bigquery-public-data.thelook_ecommerce.order_items
  INNER JOIN 
    bigquery-public-data.thelook_ecommerce.products
    ON order_items.product_id = products.id
  LEFT JOIN 
    bigquery-public-data.thelook_ecommerce.order_items AS order_destination
    ON order_items.order_id = order_destination.order_id
    AND order_items.product_id != order_destination.product_id
  LEFT JOIN 
    bigquery-public-data.thelook_ecommerce.products AS products_destination
    ON order_destination.product_id = products_destination.id
),

order_combinations AS(
  SELECT
    order_id,
    created_at,
    categories,
  FROM
    duplicate_order_combinations
  GROUP BY
      order_id,
      created_at,
      categories
)

SELECT
  created_at,
  SPLIT(categories, "-")[offset(0)] AS category, 
  CASE WHEN categories LIKE "%-%" 
    THEN SPLIT(categories, "-")[offset(1)] ELSE NULL END AS other_category, 
  COUNT(*) AS _count
FROM 
  order_combinations
GROUP BY
  created_at,
  categories

以下のような感じで集計される。以下のレコードだとAccessoriesとOuterwear & Coatsの同時購入が2020-05に20件あったということになる。

created_at, category, other_category, _count
2022-05-01, Accessories, Outerwear & Coats, 20

この結果にdataという名称をつけておく。

また、カテゴリの一覧も取得してcategoriesという名称をつけておく。

SELECT distinct(category)
FROM bigquery-public-data.thelook_ecommerce.products

グラフ化

グラフを扱えるNetworkX と可視化を行う Matplotlib を使用してグラフ化している。

以下の環境で実行を確認した。

Python 3.8.16
networkX 2.8.8
pandas 1.3.5
matplotlib 3.2.2

大まかな流れとして

1. ノード(今回だと購入した商品のカテゴリ)とエッジ(今回だと同時に購入した商品のカテゴリをつなぐ)を作成する

2. ノードとエッジを描画する(多い組み合わせほど太いエッジとなるようにし、同じカテゴリで複数購入された場合は同じノードを環状につなぐようにした)

import networkx as nx
import pandas as pd
from matplotlib import pyplot as plt

nlist = [
  ['Pants', 'Shorts', 'Skirts', 'Pants & Capris', 'Jeans'],
  ['Tops & Tees', 'Sweaters', 'Fashion Hoodies & Sweatshirts', 'Blazers & Jackets', 'Suits & Sport Coats', 'Outerwear & Coats'],
  ['Jumpsuits & Rompers', 'Dresses', 'Suits', 'Clothing Sets', 'Active', 'Swim', 'Maternity', 'Sleep & Lounge'],
  ['Socks', 'Socks & Hosiery', 'Leggings', 'Intimates', 'Underwear', 'Plus', 'Accessories']
]  # 使用しているshell_layoutの引数 nlistで同じリストに属するカテゴリが同じ円状に配置される


def make_graph(data, term, categories, origin_column, destination_column):
  data_internal = data[data[destination_column].isnull()]
  data_external = data[~data[destination_column].isnull()]
  G = nx.Graph()

  # nodeの作成
  for category in categories:
    counts = data_internal.query(f'{origin_column} == @category')['_count'].values
    if len(counts) > 0:
      G.add_nodes_from([(category, {'count': counts[0]})])
    else:
      G.add_nodes_from([(category, {'count': 1})])  # 単品での購入が行われていない場合

  # edgeの作成
  for origin, destination in zip(data_external[origin_column], data_external[destination_column]):
    weight = data_external.query(f'{origin_column} == @origin and {destination_column} == @destination')['_count'].values
    if len(weight) > 0:
      G.add_edge(origin, destination, weight=weight[0])
    else:
      G.add_edge(origin, destination, weight=0)
  
  return G


def plot_graph(G, term, nlist):
  # graphの描画
  plt.figure(figsize=(15, 15))
  pos = nx.shell_layout(G, nlist=nlist)

  node_size = [d['count']*8 for (n, d) in G.nodes(data=True)]
  nx.draw_networkx_nodes(G, pos, node_color='w', edgecolors='b', alpha=0.6, node_size=node_size)
  nx.draw_networkx_labels(G, pos)
  edge_width = [d['weight']*0.08 for (u, v, d) in G.edges(data=True)]
  edge_color = edge_width / max(edge_width)
  nx.draw_networkx_edges(G, pos, alpha=0.6, edge_color=edge_color, width=edge_width, edge_cmap=plt.cm.cool)

  plt.axis('off')
  title = f'{pd.to_datetime(term).year}-{pd.to_datetime(term).month}'
  plt.title(title, loc='left', fontsize=24)
  plt.savefig(f'{title}.png')


for month in data['created_at'].unique():
  data_month = data.query('created_at==@month')
  G = make_graph(data_month, month, categories['category'], 'category', 'other_category')
  plot_graph(G, month, nlist)

以下のような感じで月毎に画像が生成される。

動画化

動画への変換は OpenCV を利用した。上記で月毎に画像を生成した際、年月をタイトルとして保存してあるので、その順で重ねて動画にしている。

import cv2


def movie_export(prefix):
  fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
  video  = cv2.VideoWriter(f'{prefix}_movie.mp4', fourcc, 6.0, (1080, 1080))

  for month in sorted(data['created_at'].unique()):
    title = f'{pd.to_datetime(month).year}-{pd.to_datetime(month).month}'
    img = cv2.imread(f'/content/{title}.png')
    video.write(img)
  video.release()


movie_export('category')

次のような動画が生成される。