はじめに
私は、BigQueryのGitHubデータを使って、GitHubリポジトリにある上位3,500個のPythonパッケージの共起を抽出し、速度ベルレ積分を使ってd3のForceレイアウトを可視化してみました。また、python-igraphにあるアルゴリズムを使ってグラフをクラスタ化し、http://graphistry.com/にアップデートしました。
以下のスクリーンショットは、d3の可視化にあるNumPyのクラスタです(画像をクリックするとライブ版をご覧いただけます)。
以下は、graphistrynによって抽出されたNumPyのクラスタです(画像をクリックするとライブ版をご覧いただけます)。
グラフの特徴:
- 各ノードは、GitHubで見つけることのできる、それぞれのPythonパッケージです。半径は、ノードのDataFrameセクションで計算されています。
- AとBの2つのパッケージ間において、エッジの重みは
で、
は同じファイル内にあるパッケージAとBの発生頻度を現しています。BigQueryを使って計算することは困難なので、これをすぐにNormalized Pointwise Mutual Information (npmi)に移行します。
- 0.1よりも小さい重みのエッジは、削除します。
- d3のアルゴリズムは、シミュレーションパラメータに従って、速度ベルレ積分により最小限のエネルギー状態を検索します。
私のアプリケーションは、http://clustering.kozikow.com/?center=numpyでご覧いただくことができます。以下のことが可能です。
- URL内で、クエリの引数として異なるパッケージ名を渡すことができる。
- ページを上下左右にスクロールすることができる。
- ノードをクリックしてPyPIを開くことができます。ただし、全てのパッケージがPyPIにあるわけではない。
graphistryのビューで興味深いものについては、次のセクションである特定のクラスタの分析でご紹介します。
グラフの可視化は、見た目が恰好いいということを除いては、時に実用的な洞察を得られないこともあります。使用できる実用的な洞察は以下のとおりです。
- 使用している他のパッケージの近くにある、認識できていないパッケージを見つける。
- サイズや採択、ライブラリ化ができるかどうかによって、異なるWeb開発フレームワークを評価する(例えばFlask対django)。
- roboticのクラスタといったような、面白いPythonのユースケースを見つける。
この投稿の編集履歴はgithubで閲覧可能であり、これはorg modeになっています。
特定のクラスタの分析
d3の可視化結果に加え、更にpython-igraphにあるcommunity_infomap().membership
を使ってデータをクラスタ化し、それをgraphistryにアップロードしました。クラスタによる除外機能やフィルタ機能は、とても便利です。
科学計算クラスタ
当然のことですが、それはNumPyが中心です。面白いことに、統計学とマシンラーニングでは相違が見られます。
Webフレームワークのクラスタ
Webフレームワークは興味深い機能群です。
d3のリンク
- sqlalchemyは、Webフレームワークの中心と言うことができるでしょう。
- 近くには、djangoに向かう、比重が大きくて巨大なクラスタがあります。
- 近くには、flaskとpyramidに向かう、より小さなクラスタがあります。
- djangoとsqlalchemyの間には、それ自身のクラスタが欠けているpylonsがあります。
- sqlachemyの近くにも、zopeに向かう、小さなクラスタがあります。
- 真ん中には、標準ライブラリの大きなクラスタによって吸収されたtornadoがありますが、依然として他のWebフレームワークに隣接しています。
- gluon(web2py)やturbo gearsといった小さなWebフレームワークは、最終的にgyangoの近くで終了していますが、それらのクラスタなしでは、はっきりと確認することができません。
興味深いgraphistryのクラスタ
その他の興味深いクラスタ
クラスタ化のアルゴリズムの結果を見てみると、”中間サイズ”のクラスタだけに興味を引き付けられます。真っ先に目を引くことが明らかなものを挙げると、osやsysのようなパッケージが多数を占めるクラスタです。クラスタが非常に小さいと、これに興味を引き付けられることはありません。こちらの図は、サイズが5~30のクラスを表示させたものです。
以下にその他のクラスタを記載しておきました。
- テストのクラスタ:d3のリンク、graphistryのクラスタ
- OpenStackのクラスタ:d3のリンク、graphistryのリンク
- 文字列の構文解析とフォーマットのクラスタ
- Robotics
- ゲーミングのクラスタ
- ディープラーニングのクラスタ
更なる分析の可能性
他のプログラミング言語
今回のコードの大部分は、Phytonに特化したものではありません。最初のステップであるパッケージでテーブルを作成するだけがPhytonに特化したコードです。
グラフの見栄えが良くなるように、シミュレーションパラメータでパラメータを調整するのに相当な時間を費やしました。各言語のグラフは、異なる特徴を持っているので、恐らく、他の各言語に対しても同様の適合を行う必要があるのでしょう。
この次にはJavaとScalaの分析を行おうと思います。
seaborn対bokehなど、”パッケージXに対する代替え案”の探索
例えば、Pythonの可視化データパッケージを全て一緒にクラスタ化したら面白いのではないでしょうか。
直観的に、このようなパッケージは似たようなコンテキストで使用されますが、一緒に使用されることはあまりありません。「グラフはNpmiの同時発生行列Mで表現される」と仮定すると、パッケージxとyにおいては、ベクトルxとyの相関性は高くなりますが、M[x][y]は低くなるでしょう。
あるいは、M^2 /. M
にも可能性があるかもしれません。/.
は要素ごとの商で、M^2
は、グラフ上で”2つのホップ”を大まかに表現しています。
隣同士の重みだけでも相関性は高いのですが、直接的なエッジは低くなります。
これは、多くの状況で機能しますが、うまく機能しない場合もあります。以下がその例です。
- sqalchemyはdjangoのビルトインORMの代替
- django ORMはdjangoだけで使われている
- django ORMがflaskのような他のWebフレームワークの使用に適していない
- 他のWebフレームワークがflask ORMを乱用していて、djangoのビルトインORMではない
従って、django ORMとsqlalchemyは近隣の重み付けが相関されることはありません。私はあまりWeb開発を行わないので、ORMの詳細が間違っている可能性はあります。
また、node2vedの実験や、隣接行列の2乗を行う予定です。
リポジトリの関連性
現在、私は同じファイル内でのインポートだけを見ています。”同じリポジトリ内”の関連性を使って構築した同じグラフを見たり、”同じリポジトリ内”と”同じファイル内”の関連性を体系的に比較してみたりするのも面白いと思います。
PyPIとの連結
PyPIダウンロードとGitHub上の使用量を比較しても面白いでしょう。PyPIはBigQueryからもアクセスが可能です。
データ
- d3によって使用されたJSONの後処理データ
- 一般公開されている全てのデータのBigQueryテーブル。各テーブルがどのように生成されているかは、再作成のセクションをご覧ください。
再作成するステップ
BigQueryからのデータ抽出
パッケージを使用したテーブル作成
以下のコードを、wide-silo-135723:github_clustering.packages_in_file_pyに保存します。
SELECT id, NEST(UNIQUE(COALESCE( REGEXP_EXTRACT(line, r"^from ([a-zA-Z0-9_-]+).*import"), REGEXP_EXTRACT(line, r"^import ([a-zA-Z0-9_-]+)")))) AS package FROM ( SELECT id AS id, LTRIM(SPLIT(content, "\n")) AS line, FROM [fh-bigquery:github_extracts.contents_py] HAVING line CONTAINS "import") GROUP BY id HAVING LENGTH(package) > 0;
このSQLにより生成されるテーブルは、2つのフィールドを持ちます。つまり、ファイルを示すidと、1つのファイル内にあるパッケージによる繰り返しのフィールドです。繰り返しのフィールドは、配列に似ています。このページに、繰り返しのフィールドのベストな表現を見付けました。
このステップのみpython特有のものです。
packages_in_file_pyのテーブルを実証
いくつかファイルを無作為に選び、インポートがここまで正しくパースされているか確認してください。
SELECT GROUP_CONCAT(package, ", ") AS packages, COUNT(package) AS count FROM [wide-silo-135723:github_clustering.packages_in_file_py] WHERE id == "009e3877f01393ae7a4e495015c0e73b5aa48ea7"
packages | count |
---|---|
distutils, itertools, numpy, decimal, pandas, csv, warnings, future, IPython, math, locale, sys | 12 |
人気のないパッケージをフィルタリング
SELECT COUNT(DISTINCT(package)) FROM (SELECT package, count(id) AS count FROM [wide-silo-135723:github_clustering.packages_in_file_py] GROUP BY 1) WHERE count > 200;
少なくとも200回出現するパッケージが3,501個あり、そこで頭打ちとなるようです。フィルタリングされたテーブル、wide-silo-135723:github_clustering.packages_in_file_top_pyを作成します。
SELECT id, NEST(package) AS package FROM (SELECT package, count(id) AS count, NEST(id) AS id FROM [wide-silo-135723:github_clustering.packages_in_file_py] GROUP BY 1) WHERE count > 200 GROUP BY id;
結果は、「wide-silo-135723:github_clustering.packages_in_file_top_py」に格納されます。
SELECT COUNT(DISTINCT(package)) FROM [wide-silo-135723:github_clustering.packages_in_file_top_py];
3501
グラフのエッジを生成
では、エッジを生成してテーブル「wide-silo-135723:github_clustering.packages_in_file_edges_py」に保存します。
SELECT p1.package AS package1, p2.package AS package2, COUNT(*) AS count FROM (SELECT id, package FROM FLATTEN([wide-silo-135723:github_clustering.packages_in_file_top_py], package)) AS p1 JOIN (SELECT id, package FROM [wide-silo-135723:github_clustering.packages_in_file_top_py]) AS p2 ON (p1.id == p2.id) GROUP BY 1,2 ORDER BY count DESC;
上位10個のエッジは以下のSQLで求められます。
SELECT package1, package2, count AS count FROM [wide-silo-135723:github_clustering.packages_in_file_edges_py] WHERE package1 < package2 ORDER BY count DESC LIMIT 10;
package1 | package2 | count |
---|---|---|
os | sys | 393311 |
os | re | 156765 |
os | time | 156320 |
logging | os | 134478 |
sys | time | 133396 |
re | sys | 122375 |
__future__ | django | 119335 |
__future__ | os | 109319 |
os | subprocess | 106862 |
datetime | django | 94111 |
必要の無いエッジをフィルタリング
エッジの重みの分位は以下のように求めます。
SELECT GROUP_CONCAT(STRING(QUANTILES(count, 11)), ", ") FROM [wide-silo-135723:github_clustering.packages_in_file_edges_py];
1, 1, 1, 2, 3, 4, 7, 12, 24, 70, 1005020
最初の実行時には、全てのカウントに基づいてエッジをフィルタリングしました。これは良いアプローチではありませんでした。極端に小さいパッケージ間にある強い関連性よりも、大きなパッケージ2つの間にある弱い関連性の方が、残ってしまう傾向があるためです。
wide-silo-135723:github_clustering.packages_in_file_nodes_pyを作成します。
SELECT package AS package, COUNT(id) AS count FROM [github_clustering.packages_in_file_top_py] GROUP BY 1;
package | count |
---|---|
os | 1005020 |
sys | 784379 |
django | 618941 |
__future__ | 445335 |
time | 359073 |
re | 349309 |
テーブルpackages_in_file_edges_top_pyを作成します。
SELECT edges.package1 AS package1, edges.package2 AS package2, # WordPress gets confused by less than sign after nodes1.count edges.count / IF(nodes1.count nodes2.count, nodes1.count, nodes2.count) AS strength, edges.count AS count FROM [wide-silo-135723:github_clustering.packages_in_file_edges_py] AS edges JOIN [wide-silo-135723:github_clustering.packages_in_file_nodes_py] AS nodes1 ON edges.package1 == nodes1.package JOIN [wide-silo-135723:github_clustering.packages_in_file_nodes_py] AS nodes2 ON edges.package2 == nodes2.package HAVING strength > 0.33 AND package1 <= package2;
Pandasからjsonへのデータ処理
csvのロードと、pandasによるエッジの実証
import pandas as pd import math df = pd.read_csv("edges.csv") pd_df = df[( df.package1 == "pandas" ) | ( df.package2 == "pandas" )] pd_df.loc[pd_df.package1 == "pandas", "other_package"] = pd_df[pd_df.package1 == "pandas"].package2 pd_df.loc[pd_df.package2 == "pandas", "other_package"] = pd_df[pd_df.package2 == "pandas"].package1 df_to_org(pd_df.loc[:,["other_package", "count"]]) print "\n", len(pd_df), "total edges with pandas"
other_package | count |
---|---|
pandas | 33846 |
numpy | 21813 |
statsmodels | 1355 |
seaborn | 1164 |
zipline | 684 |
他11行 |
Pandasによって、合計16個のエッジが求められました。
ノードによるDataFrame
nodes_df = df[df.package1 == df.package2].reset_index().loc[:, ["package1", "count"]].copy() nodes_df["label"] = nodes_df.package1 nodes_df["id"] = nodes_df.index nodes_df["r"] = (nodes_df["count"] / nodes_df["count"].min()).apply(math.sqrt) + 5 nodes_df["count"].apply(lambda s: str(s) + " total usages\n") df_to_org(nodes_df)
package1 | count | label | id | r |
---|---|---|---|---|
os | 1005020 | os | 0 | 75.711381704 |
sys | 784379 | sys | 1 | 67.4690570169 |
django | 618941 | django | 2 | 60.4915169887 |
__future__ | 445335 | __future__ | 3 | 52.0701286903 |
time | 359073 | time | 4 | 47.2662138808 |
他3460行 |
ノード名 -> idのマッピングを生成
id_map = nodes_df.reset_index().set_index("package1").to_dict()["index"] print pd.Series(id_map).sort_values()[:5]
os 0 sys 1 django 2 __future__ 3 time 4 dtype: int64
エッジのデータフレームを生成
edges_df = df.copy() edges_df["source"] = edges_df.package1.apply(lambda p: id_map[p]) edges_df["target"] = edges_df.package2.apply(lambda p: id_map[p]) edges_df = edges_df.merge(nodes_df[["id", "count"]], left_on="source", right_on="id", how="left") edges_df = edges_df.merge(nodes_df[["id", "count"]], left_on="target", right_on="id", how="left") df_to_org(edges_df) print "\ndf and edges_df should be the same length: ", len(df), len(edges_df)
package1 | package2 | strength | count_x | source | target | id_x | count_y | id_y | count |
---|---|---|---|---|---|---|---|---|---|
os | os | 1.0 | 1005020 | 0 | 0 | 0 | 1005020 | 0 | 1005020 |
sys | sys | 1.0 | 784379 | 1 | 1 | 1 | 784379 | 1 | 784379 |
django | django | 1.0 | 618941 | 2 | 2 | 2 | 618941 | 2 | 618941 |
__future__ | __future__ | 1.0 | 445335 | 3 | 3 | 3 | 445335 | 3 | 445335 |
os | sys | 0.501429793505 | 393311 | 0 | 1 | 0 | 1005020 | 1 | 784379 |
他11117行 |
dfとedges_dfは、同じ長さになるはずです。つまり、11122と11122です。
逆方向のエッジを追加
edges_rev_df = edges_df.copy() edges_rev_df.loc[:,["source", "target"]] = edges_rev_df.loc[:,["target", "source"]].values edges_df = edges_df.append(edges_rev_df) df_to_org(edges_df)
package1 | package2 | strength | count_x | source | target | id_x | count_y | id_y | count |
---|---|---|---|---|---|---|---|---|---|
os | os | 1.0 | 1005020 | 0 | 0 | 0 | 1005020 | 0 | 1005020 |
sys | sys | 1.0 | 784379 | 1 | 1 | 1 | 784379 | 1 | 784379 |
django | django | 1.0 | 618941 | 2 | 2 | 2 | 618941 | 2 | 618941 |
__future__ | __future__ | 1.0 | 445335 | 3 | 3 | 3 | 445335 | 3 | 445335 |
os | sys | 0.501429793505 | 393311 | 0 | 1 | 0 | 1005020 | 1 | 784379 |
他22239行 |
エッジDataFrameの切り捨て
edges_df = edges_df[["source", "target", "strength"]] df_to_org(edges_df)
source | target | strength |
---|---|---|
0.0 | 0.0 | 1.0 |
1.0 | 1.0 | 1.0 |
2.0 | 2.0 | 1.0 |
3.0 | 3.0 | 1.0 |
0.0 | 1.0 | 0.501429793505 |
他22239行 |
ブラウザ上でシミュレーションを実行した後、座標を保存
全体のシミュレーションは、安定するまで1分かかります。単純に画像をダウンロードすることは出来ますが、その場合はノードを押すとPyPIを開くといったような、追加の機能はありません。
次のコードで、javascriptコンソールからシミュレーション後のすべての座標をダウンロードします。
var positions = nodes.map(function bar (n) { return [n.id, n.x, n.y]; }) JSON.stringify()
座標xとyをエッジのデータフレームに結合すると、d3に渡すことができます。
pos_df = pd.read_json("fixed-positions.json") pos_df.columns = ["id", "x", "y"] nodes_df = nodes_df.merge(pos_df, on="id")
ノードDataFrameの切り捨て
# cは、衝突の強度。ラベルの重複を防ぐ。 nodes_df["c"] = pd.DataFrame([nodes_df.label.str.len() * 1.8, nodes_df.r]).max() + 5 nodes_df = nodes_df[["id", "r", "label", "c", "x", "y"]] df_to_org(nodes_df)
id | r | label | c | x | y |
---|---|---|---|---|---|
0 | 75.711381704 | os | 80.711381704 | 158.70817237 | 396.074393369 |
1 | 67.4690570169 | sys | 72.4690570169 | 362.371142521 | -292.138913114 |
2 | 60.4915169887 | django | 65.4915169887 | 526.471326062 | 1607.83507287 |
3 | 52.0701286903 | __future__ | 57.0701286903 | 1354.91212894 | 680.325432179 |
4 | 47.2662138808 | time | 52.2662138808 | 419.407448663 | 439.872927665 |
他3460行 |
jsonにファイルを保存
# カラムの切り捨て with open("graph.js", "w") as f: f.write("var nodes = {}\n\n".format(nodes_df.to_dict(orient="records"))) f.write("var nodeIds = {}\n".format(id_map)) f.write("var links = {}\n\n".format(edges_df.to_dict(orient="records")))
新しいd3の速度ベルレ積分アルゴリズムを使ったグラフの描画
物理的シミュレーションSimulationは、d3のバージョン4.0で新しく速度ベルレ積分のforce graphを利用しています。Simulationは安定するまで大体1分ほどかかります。このためグラフを表示させる目的の場合は、私は自分のマシン上でシミュレーションを実行した後で、ノードの座標をハードコードしています。
シミュレーションのコアコンポーネントは、以下の通りです。
var simulation = d3.forceSimulation(nodes) .force("charge", d3.forceManyBody().strength(-400)) .force("link", d3.forceLink(links).distance(30).strength(function (d) { return d.strength * d.strength; })) .force("collide", d3.forceCollide().radius(function(d) { return d.c; }).strength(5)) .force("x", d3.forceX().strength(0.1)) .force("y", d3.forceY().strength(0.1)) .on("tick", ticked);
シミュレーションを再実行する場合は、以下の手順が必要です。
- pandasの処理ステップの1つに追加された、固定の座標を削除
- javascriptファイル内の”forces”のコメントアウト
シミュレーションパラメータ
しばらくシミュレーションパラメータを微調整してみました。かなり密度の高いグラフの”中心”では、グラフのエッジ上でクラスタ間が衝突しています。
最新のグラフからも分かるように、中心のノードはたまに重複しています。これは、グラフのエッジにおけるノード間の距離が大きいためです。
出来る限り衝突パラメータを求めさらに増加させてみましたが、それは得策ではありませんでした。潜在的には、中心に向かって重みは増します。しかしそのため、いくつかの価値ある”クラスタ”が、グラフのエッジから中心の大きな”核”に向かって集中していったのです。
複数の大きなクラスタを分離してプロットすることは、この課題を解決するのに役立ちます。
- 引力
- パッケージA、B間のエッジの重み:
(距離:30)
- 中心に向かう重力:0.1
- パッケージA、B間のエッジの重み:
- 反発力
- ノード間の反発:-400
- ノード衝突の強度:5
その他の投稿
興味がありましたら、GitHubのデータを分析した他の記事も読んでみてください。