ネットワークの広がりの様子を可視化する(ネットワークのGIFアニの作成)

2021.10.29

2021.10.29

Fabeee社員ブログ

はじめに

はじめまして。トミーです。
今年の8月にFabeeeにジョインしました。これからよろしくお願いします。
初ブログは「ネットワーク」の可視化方法について書こうと思います。ネットワークの分析は、SNSでの話題の広がりや文書中の単語の共起構造、時世的には感染の広がりなど社会的に適用範囲の広い分野だと思っています。

今回のテーマは「ネットワークの広がりの可視化」ですが、通常、ネットワークの可視化は以下のように過去の履歴(データ)をすべて使用して作成されるケースがほとんどです。



ただ、SNSも感染もそうですが、時系列的に話題や感染がどのように広がってきたのかにもすごく興味があります。そこで、今回はこれから発展して、ネットワークが構築されていく様子をGIFアニを作って見てみたいと思います。後ほど述べますが、この可視化で分かることいっきに増えるので、そこ知っていただけたらと思います。

ネットワークの描画はNetworkX、GIFアニの作成はMatplotlibのanimationを使って実装します。
ライブラリの使い方はいくつか参考があるので解説しませんが、この2つのライブラリを使ったネットワークのアニメーションの実装の参考がほぼなく、個人的にハマった部分があるので、そこのHow toは補足しようと思います。

対象分野

​今回の対象分野として、「購買」を扱います。そのため、対象データも「購買データ」になります。
「購買でネットワーク?」と思われる方もいらっしゃるかもしれませんが、ある人が商品Aと商品Bを一緒に買うことを「つながり」と捉えればその人の併売商品のネットワークを描くことができます。
サンプルデータの中身は以下になります。仮想の顧客の一連の購買履歴です。[Cart]カラムは1回購買あたりの買い物カートの中身で、3桁の数字はそれぞれ商品を表しています。


 

通常の可視化


まずは通常のスタティックな可視化をしてみます。

# 全カートのリスト
list_cart = df['Cart'].tolist()

# ネットワークの初期化
G = nx.Graph()

# ノードとエッジの追加
for cart in list_cart:

# ノード
for node in cart:
if G.has_node(node):
G.nodes(data=True)[node]['size'] += 1
else:
G.add_node(node, size=1)

# エッジ
if len(cart) > 1:
for node0, node1 in itertools.combinations(cart, 2):
# if node0 != node1:
if G.has_edge(node0, node1):
G[node0][node1]['weight'] += 1
else:
G.add_edge(node0, node1, weight=1)

# 配置
pos = nx.spring_layout(G, k=0.6)

# ノードサイズ
node_size = [d['size']*600 for _, d in G.nodes(data=True)]

# エッジの太さ
edge_width = [d['weight']*0.8 for _, _, d in G.edges(data=True)]

# 描画
fig, ax = plt.subplots(1, 1, figsize=(19.2, 10.8))
ax.axis('off')

nx.draw_networkx_nodes(G, pos, node_color='violet', node_size=node_size, edgecolors='k', ax=ax)
nx.draw_networkx_labels(G, pos, font_size=10, font_color='k', font_weight='bold', ax=ax)
nx.draw_networkx_edges(G, pos, edge_color='grey', width=1, ax=ax)

plt.show()

これを実行すると以下のようになります。



ノードとエッジのサイズも表示しているので、商品単体としてどれを多く買っていて、どの商品とどの商品が併売されやすいかもわかるかと思います。

通常の可視化で読み解けないこと

これでもいいのですが、冒頭でも述べたように時系列的な情報が一切わからないので、例えば

  • 1回の買い物リストに過去から現在でどのように違いがあるのか?
  • 1回の買い物リストに周期性があるのか?
  • 買う商品のカテゴリ(キッチンまわりなど、商品グループのこと)の広がり方がどのようになっているか?
  • 時系列的に媒介性の高い商品はあるか?


などは分析できません。
最後の項目だけ少し補足します。SNSで例えるとわかりやすいですが、話題が途中で有名なインフルエンサーを経由して爆発的な広がり見せるみたいな現象です。購買でも最初は限られたカテゴリしか買ってなかったけど、ある別のカテゴリの商品を買ってからそのカテゴリやそのまた別のカテゴリ・・・と広がっていった様子が見られれば、そのトリガーとなった商品の貢献は大きいと言えます。カテゴリの広がりは顧客のLTVの向上に大きく貢献するので、その辺りの分析はとても重要です。

GIFアニの作成


さて、本題であるGIFアニの作成に移りたいと思います。スタティックな可視化ではデータの全レコードをいっきにネットワークに落とし込みましたが、今回は1レコードずつ(つまり1回購買ずつ)のネットワークをフレームに落として、それをGIFアニに変換します。

# 全カートのリスト
list_cart = df['Cart'].tolist()

# フレームの枚数
frames = df['Date'].count()

# 日付のリスト
date = df['Date'].tolist()

# 色の情報(表示しているフレームのノードとエッジをアクティブにする)
color_active = 'violet'
color_non_active = 'lightgrey'

# 最終配置の情報を取得
# ****************************ここから****************************
# ネットワークの初期化
G = nx.Graph()

# 全ノードの追加
G.add_nodes_from(list(set(itertools.chain.from_iterable(list_cart))))

# 全エッジの追加
for cart in list_cart:
  if len(cart) > 1:
    for node0, node1 in itertools.combinations(cart, 2):
      if G.has_edge(node0, node1): continue
      else: G.add_edge(node0, node1)

# 配置の情報
pos = nx.spring_layout(G, k=0.6)
# ****************************ここまで****************************

# ネットワークをクリア
G.clear()

# 描画の設定
fig, ax = plt.subplots(1, 1, figsize=(19.2, 10.8))

# フレームの軸を固定
array_pos = np.array(list(pos.values()))
max_xy, min_xy = np.max(array_pos, axis=0), np.min(array_pos, axis=0)
range_x, range_y = max_xy[0] - min_xy[0], max_xy[1] - min_xy[1]
xlim = [min_xy[0] - range_x*0.05, max_xy[0] + range_x*0.05]
ylim = [min_xy[1] - range_y*0.05, max_xy[1] + range_y*0.05]

# 描画の初期関数
def init_func():
  pass

# 描画のアップデート関数
# ****************************ここから****************************
def update(i):

  # ノードとエッジの色をリセット
  if i != 0:
    for node in G.nodes():
      G.nodes(data=True)[node]['color'] = color_non_active
    for node0, node1 in G.edges():
      G[node0][node1]['color'] = color_non_active
  
  # ノードとエッジの追加
  # ノード
  for node in list_cart[i]:
    if G.has_node(node):
      G.nodes(data=True)[node]['count'] += 1
      G.nodes(data=True)[node]['color'] = color_active
    else:
      G.add_node(node, count=1, color=color_active)
  
  # エッジ
  if len(list_cart[i]) > 1:
    for node0, node1 in itertools.combinations(list_cart[i], 2):
      if G.has_edge(node0, node1):
        G[node0][node1]['weight'] += 1
        G[node0][node1]['color'] = color_active
      else:
        G.add_edge(node0, node1, weight=1, color=color_active)

  # ノードサイズ
  node_size = [d['count']*600 for _, d in G.nodes(data=True)]

  # エッジの太さ
  edge_width = [d['weight']*0.8 for _, _, d in G.edges(data=True)]

  # ノードとエッジの色
  node_color = [d['color'] for _, d in G.nodes(data=True)]
  edge_color = [d['color'] for _, _, d in G.edges(data=True)]

  # 描画
  # リセット
  ax.cla()
  ax.set_xlim(xlim[0], xlim[1])
  ax.set_ylim(ylim[0], ylim[1])
  ax.axis('off')
  fig.suptitle('#{}   {}'.format(i+1, date[i].strftime('%Y/%m/%d')), fontsize=30)

  nx.draw_networkx_nodes(G, pos, node_color=node_color, node_size=node_size, edgecolors='k', ax=ax)
  nx.draw_networkx_labels(G, pos, font_size=10, font_color='k',  font_weight='bold', ax=ax)
  nx.draw_networkx_edges(G, pos, edge_color=edge_color, width=edge_width, ax=ax)

  if i == frames - 1: time.sleep(2.0)
# ****************************ここまで****************************

# アニメーション作成
anim = animation.FuncAnimation(fig, update, frames=frames, init_func=init_func, interval=1500)

# アニメーションを保存
anim.save(filepath, writer='imagemagick')

>>>

これを実行すると以下のようになります。



どうでしょうか?​
時系列に沿ってネットワークが広がっていく様子やノードやエッジの成長具合などなど可視化できているかと思います。​ここから先程述べたような時系列の分析をしていくことになります。​
サンプルデータも時系列的に特徴のあるような教材データみたいにできればよかったのですが、めんどうなのと今回の本質ではないので、分析はぜひ実データを使ってトライしてみてください。

コードの補足


この実装にあたって個人的にハマった部分があるので最後に補足したいと思います。​
まず、このGIFアニを作成するにはノードの位置を固定する必要がありますが、以下2点の弊害があります。

  • NetworkXはいい感じにノードの配置を決めてくれるが、描画するデータセットが変わるとその配置も変わる。
  • Matplotlibでは軸の大きさが描画するデータセットで自動最適される。


今回、毎フレームごとそれまでの履歴分のデータのみネットワークに落とし込んでいるので、毎回描画するデータセットが違う状態になっています。​
まず1点目ですが、フレーム単位で描画する前にネットワークの完成型を一度作成して、そこから各ノードの位置情報だけ取得しておきます。毎回この位置で描画すればノードはずれることはありません。

# 最終配置の情報を取得
# ****************************ここから****************************
# ネットワークの初期化
G = nx.Graph()

# 全ノードの追加
G.add_nodes_from(list(set(itertools.chain.from_iterable(list_cart))))

# 全エッジの追加
for cart in list_cart:
  if len(cart) > 1:
    for node0, node1 in itertools.combinations(cart, 2):
      if G.has_edge(node0, node1): continue
      else: G.add_edge(node0, node1)

# 配置の情報
pos = nx.spring_layout(G, k=0.6)
# ****************************ここまで****************************

>>>

次に2点目ですが、これも1点目同様に、あらかじめネットワークの完成形のノードの位置情報をもとに軸の大きさを決めておきます。

# 軸を固定
array_pos = np.array(list(pos.values()))
max_xy, min_xy = np.max(array_pos, axis=0), np.min(array_pos, axis=0)
range_x, range_y = max_xy[0] - min_xy[0], max_xy[1] - min_xy[1]
xlim = [min_xy[0] - range_x*0.05, max_xy[0] + range_x*0.05]
ylim = [min_xy[1] - range_y*0.05, max_xy[1] + range_y*0.05]

>>>

最後に

​いかがでしたでしょうか?
個人的には分析は可視化で決まるといってもいいぐらい可視化の重要性は高いと思っています。
​ただ、同じデータセットでも可視化の方法で見えることやわかりやすさが変わってくるので、いろんな可視化をトライしてみてください!

Fabeee編集部

Fabeee編集部

こちらの記事はFabeee編集部が執筆しております。