📈

Gephi Toolkit で大きなグラフを可視化する

2023/01/09に公開

はじめに

(グラフ理論の)グラフの可視化には、netwrokxigraphといったツールが良く用いられます。しかし、これらのツールは、ノード数が多い場合に可視化がうまく機能しません。

そこで今回の記事では、比較的大きなグラフの可視化を行えるライブラリである、Gephi Toolkitについて紹介します(備忘録を付けます)。

Gephiとは

Gephiとはグラフを可視化するためのOSSです。
https://gephi.org/
GephiはGUI上でグラフを可視化できる便利なツールで、分析のためにグラフを可視化したい場合、個人的には前述したツールよりも使いやすいのではないかと考えています。

Gephiは比較的大きなグラフを扱うことができ、GMLやGraphML形式をはじめ、networkx等で出力したグラフを読み込むことも可能です。また、MySQLなどのRDBMSからグラフデータを読み込むこともできます。

ただし、単一のコンピュータ上で動作するソフトウェアですので、GraphXのように大規模なデータを解析できるわけではありません。networkx/igraph < Gephi < GraphX なのかなと、個人的には思います。

Gephi Toolkit とは

単に巨大なグラフを可視化したいだけであれば、Gephiを用いるだけで事足ります。問題は、GephiはGUIで操作するソフトウェアであり、可視化作業を大量のグラフに対して繰り返し行ったり、ソフトウェアの機能の一部として組み込んだりすることができない点です。

そのような需要に答えるため、GephiプロジェクトはGephiの機能をJavaライブラリとして提供するGephi Toolkitというライブラリを公開しています。
https://gephi.org/toolkit/
Javaのライブラリであるため、Javaのみならず、KotlinなどJVMで動作する言語で利用できます。

本記事ではKotlinを用いて解説します。自分もそうなのですが、Pythonで可視化できないグラフを可視化したいというモチベーションの方が多いと思いましたので、Pythonに近いKotlinで説明します。なお、筆者はJava系の知識があまりないので、その辺はご容赦ください。

Gephi Toolkitによる可視化

環境構築

InteliJ IDEAなどを導入し、Kotlinでコマンドラインアプリケーションを作成できる環境を準備します。

公式のドキュメントが少ない関係で、強力なIDEの利用をお勧めします。IDEがどんなメソッドが利用できるのかといった情報を教えてくれます。

その後、プロジェクトを作成し、プロジェクトにGephi Toolkitのライブラリを追加します。追加の方法には、

  • Mavenからgephi-toolkitを追加する方法
  • ダウンロードしたGephi Toolkitのjarファイルを直接追加する方法

の2つがあります。理由がなければ、Mavenを利用するといいでしょう。InteliJ IDEAでは「モジュール設定を開く(F4)」=>「ライブラリ」から追加できます。

サンプルについて

Javaによるサンプルは以下で読めます。基本的にこれを参考にしてコーディングします。
https://github.com/gephi/gephi-toolkit-demos

import

以下で利用しているコードのimportはこんな感じになっています。無駄なものもあるかも。
だいたいはIDEがなんとかしてくれます。

import org.gephi.appearance.api.AppearanceController
import org.gephi.appearance.api.PartitionFunction
import org.gephi.appearance.plugin.PartitionElementColorTransformer
import org.gephi.appearance.plugin.RankingNodeSizeTransformer
import org.gephi.appearance.plugin.palette.Palette
import org.gephi.appearance.plugin.palette.PaletteManager
import org.gephi.appearance.plugin.palette.Preset
import org.gephi.filters.api.FilterController
import org.gephi.filters.api.Query
import org.gephi.filters.api.Range
import org.gephi.filters.plugin.graph.DegreeRangeBuilder.DegreeRangeFilter
import org.gephi.graph.api.*
import org.gephi.io.exporter.api.ExportController
import org.gephi.io.importer.api.Container
import org.gephi.io.importer.api.EdgeDirectionDefault
import org.gephi.io.importer.api.ImportController
import org.gephi.io.processor.plugin.DefaultProcessor
import org.gephi.layout.plugin.openord.OpenOrdLayout
import org.gephi.layout.plugin.openord.OpenOrdLayoutBuilder
import org.gephi.preview.api.PreviewController
import org.gephi.preview.api.PreviewProperty
import org.gephi.project.api.ProjectController
import org.gephi.project.api.Workspace
import org.openide.util.Lookup
import java.awt.Color
import java.awt.Font
import java.io.File
import java.io.IOException

ProjectとWorkspaceの初期化

GephiはGUIアプリケーションであり、プロジェクトとワークスペースという概念があります。はじめに、これを設定する必要があります。

val pc: ProjectController = Lookup.getDefault().lookup(
	ProjectController::class.java
)
pc.newProject()
val workspace: Workspace = pc.currentWorkspace

グラフの読み込み

次に、networkxやigraphなどで、作成したグラフファイルを読み込みます。GMLやGraphMLなど、たいていの形式は読み込めます。

余談ですが、グラフの保存形式にGMLを用いるのはやめたほうがいいと考えています。GMLは古い形式で、利用できる文字が7ビットでエンコード可能でなければならないと定められています。そのため、原則的に日本語を含むマルチバイト文字が利用できません。個人的にはXMLベースのGraphML形式(*.graphml)が良いと思います。

val importController = Lookup.getDefault().lookup(
	ImportController::class.java
)
val container: Container
try {
	val file = File("読み込むグラフファイル")
	container = importController.importFile(file)
	container.loader.setEdgeDefault(EdgeDirectionDefault.UNDIRECTED) //Force UNDIRECTED
	importController.process(container, DefaultProcessor(), workspace)
} catch (ex: Exception) {
	ex.printStackTrace()
	//Error処理
}

読み込むグラフの有向・無向などをこのタイミングで指定します。読み込むファイルの拡張子から、いい感じにロードしてくれます。workspaceは「ProjectとWorkspaceの初期化」で宣言した変数です。ですので、「ProjectとWorkspaceの初期化」と同じスコープに書くのが良いでしょう。

ところで、ロードしたグラフはどこに行ったのかという疑問がこのコードを見ると湧いてくると思います。実は、ロードしたグラフは以下のように読みだせます。

val graphModel = Lookup.getDefault().lookup(GraphController::class.java).graphModel
val graph = graphModel.undirectedGraph
println("Nodes: ${graph.nodes.count()}")
println("Edges: ${graph.edges.count()}")

このコードはグラフファイルを読み込んだ後ならば、グラフファイルを読み込んだコードのスコープ外でも実行できます。Lookup.getDefault().lookup(~)というのがデータを拾ってくるコードなのですが、このメソッドはorg.openide.lookupというモジュールのものです。GUIアプリケーション特有の書き方といえます。書いているコードのスコープとは別の場所にすべての実体が存在しており、lookupを通じて、そこにアクセスしている、といったイメージになります。なので、スコープを意識せずめちゃくちゃに書けます(良くも悪くも)。以降のコードもだいたいスコープを意識する必要がありません。

グラフの可視化・保存

読みだしの次は可視化・保存です。Gephiのプレビュー機能を呼び出して可視化を行い、エクスポート機能で保存します。

Preview

プレビューではノードラベルの表示有無やそのフォント設定などを行います。環境によっては明示的に日本語フォントを指定する必要があります。

val model = Lookup.getDefault().lookup(PreviewController::class.java).model
model.properties.putValue(PreviewProperty.SHOW_NODE_LABELS, true)
val font = Font("IPA Gothic", Font.PLAIN, 8)
model.properties.putValue(PreviewProperty.NODE_LABEL_FONT, font)

Export

これだけです。

val ec = Lookup.getDefault().lookup(ExportController::class.java)
try {
	ec.exportFile(File("出力ファイル名"))
} catch (ex: IOException) {
	ex.printStackTrace()
	//Error処理
}

出力ファイル名の拡張子に応じて、良しなにやってくれます。PDFやSVG、PNGなどが利用できます。

しかし、PDF出力はマルチバイト文字の出力に問題があるようです。、SVG出力を利用するのが良いのではないかと思います。

レイアウトアルゴリズムの適用

グラフの読み込みと可視化エクスポートの間に、レイアウトや色分け、フィルタ処理を行い、グラフの見栄えを変更できます。はじめに、レイアウトしてみましょう。

OpenOrdアルゴリズムを例にとってみます。自作の関数を示します。

fun layoutByOpenOrd(graphModel: GraphModel) {
    val layout = OpenOrdLayout(OpenOrdLayoutBuilder())
    layout.setGraphModel(graphModel)
    layout.resetPropertiesValues()
    layout.initAlgo()
    for(i in 1..layout.numIterations) {
        layout.goAlgo()
        if (!layout.canAlgo()) break
    }
    layout.endAlgo()
}

引数のgraphModel: GraphModelは、グラフの読み込み時に出てきた、

val graphModel = Lookup.getDefault().lookup(GraphController::class.java).graphModel

のことを指します。

重要なことは、layout.resetPropertiesValues()でレイアウトアルゴリズムの初期値を得ること、layout.goAlgo()でレイアウトアルゴリズムを1ステップ進めること、layout.canAlgo()でレイアウトアルゴリズムが収束していないか確認すること、そして、収束しなかった場合に備えて、for文で実行することです。GephiのOpenOrdnumIterationsのプロパティを持っていますから、その初期値をそのまま利用しています。

他のレイアウトアルゴリズムも同じように適用できると思います。

フィルタの適用

次数(あるノードのエッジ数)が1のノードを隠すフィルタの実装を示します。

fun hideOneDegreeNodes(graphModel :GraphModel) {
    val graph = graphModel.undirectedGraph

    val filterController = Lookup.getDefault().lookup(
        FilterController::class.java
    )

    val degreeFilter = DegreeRangeFilter()
    degreeFilter.init(graph)
    degreeFilter.range = Range(2, Int.MAX_VALUE) //Remove degree=1

    val query: Query = filterController.createQuery(degreeFilter)
    val view: GraphView = filterController.filter(query)
    graphModel.visibleView = view
}

このような具合にフィルタを適用できます。

次数によってノードサイズを変える(Ranking)

次数によってノードサイズを変えます。

val graphModel = Lookup.getDefault().lookup(GraphController::class.java).graphModel
val appearanceController = Lookup.getDefault().lookup(
	AppearanceController::class.java
)
fun rankSizeByDegree(graphModel: GraphModel, appearanceController: AppearanceController) {
    val appearanceModel = appearanceController.model

    val degreeRanking = appearanceModel.getNodeFunction(
        graphModel.defaultColumns().degree(),
        RankingNodeSizeTransformer::class.java
    )
    val degreeTransformer: RankingNodeSizeTransformer = degreeRanking.getTransformer()
    degreeTransformer.minSize = 10f
    degreeTransformer.maxSize = 72f
    appearanceController.transform(degreeRanking)
}

属性値によってパレットで色を塗り分ける

GraphMLではノードやエッジに属性を振ることができます。その属性の値によって、ノードを色分けしてみます。Gephiでは色分けにグラデーションとパレットの2種類が利用できます。グラデーションの方はサンプルがあってわかりやすいのですが、パレットの場合はサンプルがなく大変でした。なので、今回はパレットによる塗分けについて紹介します(備忘録を付けます)。

clusterというノードの属性値に、クラスタの番号が入っていると仮定します。このクラスタ番号によってノードを色分けします。

属性名のエイリアスを解決する

GraphML形式は少し複雑で、属性名を後から変えられるようにか、以下のようなエイリアス設定があります。

<?xml version='1.0' encoding='utf-8'?>
<graphml xmlns="http://graphml.graphdrawing.org/xmlns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
  <key id="d4" for="edge" attr.name="resource" attr.type="string" />
  <key id="d3" for="edge" attr.name="label" attr.type="string" />
  <key id="d2" for="node" attr.name="cluster" attr.type="long" />
  <key id="d1" for="node" attr.name="resource" attr.type="string" />
  <key id="d0" for="node" attr.name="label" attr.type="string" />
  <graph edgedefault="undirected">
    <node id="...">
...

この例では、属性名はclusterでも、参照すべきカラム名はd2となります。GephiではColumn.titleclusterColumn.idd2となり、基本的にidの方を利用します。属性で色分けなどをする際には注意が必要です。

以下のような頭の悪いコードで動的に属性名からidを得ることができます。

var colId = ""
for (col in graphModel.nodeTable){
	if (col.title == "cluster") {
		colId = col.id
		break
	}
}

パレットを初期化する

パレットというのは以下のようなものです。

値ごとにいい感じに色を割り振ってくれます。

val intensePreset = Preset(
	"Intense",
        false,
        0,
        360,
        0.6f,
        3f,
        0.2f,
        1.1f
)   
val palette: Palette = PaletteManager.getInstance().generatePalette(
	clusterCount,
	intensePreset
)

このようにして、パレットを作成します。clusterCountは何色に分けるかの値で、今回はグラフのクラスタ数です。

Presetの引数なんやねんという話なのですが、
https://github.com/gephi/gephi/blob/master/modules/AppearancePlugin/src/main/resources/org/gephi/appearance/plugin/palette/palette_presets.csv
にGephiのデフォルトパレットのPreset引数が書いてありますので、これをセットすれば利用できます。

色分けする

後はそんなに難しくないです。

val clusterColumn: Column = graphModel.nodeTable.getColumn(colId)  //色分けする属性を選択します。colIdが存在しないと例外になります。
val appearanceModel = appearanceController.model
val clusterPartitionFunction = appearanceModel.getNodeFunction(
	clusterColumn,
	PartitionElementColorTransformer::class.java
)
val clusterPartition = (clusterPartitionFunction as PartitionFunction).partition
clusterPartition.setColors(graphModel.graph, palette.colors);
appearanceController.transform(clusterPartitionFunction)

終わりに

上記のように、Gephi Toolkitを用いると比較的大きなグラフを可視化できます。GUIソフトウェアの機能を「借りて」利用する都合上、少し書き方が独特ですが、フィルタ機能をはじめ強力な分析・可視化機能が利用できるのは大きな魅力です。

少し難しいのはライセンスでしょうか。Gephi ToolkitはGephiと同じく、GPLv3とCDDL1.0のデュアルライセンスです。基本はCDDLで利用していいはずなのですが、筆者はその辺には明るくないので、コンプライアンスが重要な現場で利用する際にはご注意ください。

備忘録のつもりで簡単に書いたものになりますが、間違いや意見があればコメントくださいませ。

Discussion