地理空間データを異種GNN向けのグラフ表現に変換するPythonライブラリを作ったらバズった
はじめに
初めまして。イギリスのリバプール大学で博士課程をしている佐藤と申します。
リバプールといえばサッカーとビートルズで有名ですが、特段そのために渡英した訳ではありません…。
イギリスでは人文地理学(都市経済学・経済地理学・都市計画・人口統計学)とデータサイエンスの複合領域である地理空間データサイエンス (Geographic Data Science)という研究分野が盛んで、そこに特化した研究所があるため在籍しています。
専門は都市形態学という都市の物理的な構造から社会を理解する分野です。特に私は都市形態のトポロジーに興味があり、非線形的なパターン分類で成果を上げているグラフ表現学習の応用に興味を持っています。グラフ表現学習にはスペクトラルクラスタリングやnode2vecなどの古典的なモデルが存在する他、ノード・エッジ・グラフの特徴量とそれらのトポロジー上の相対的な位置関係の両方を扱えるグラフニューラルネットワーク(Graph Neural Network)が近年では注目を集めています。特に、複数のノードタイプとエッジタイプが混在する異種グラフ(Heterogeneous Graph)に対するモデルは盛んに提案されており、各分野での応用実装が進み始めています。
課題感
地理空間データについても様々な離散的な関係を定義することができます。一番分かりやすい例は街路ネットワークです。OpenStreetMapのデータについては、OSMNxを用いてNetworkXのオブジェクトに変換出来る事で有名です。
都市空間には街路ネットワーク以外にも、建物間の隣接関係、建物と街路の接続関係、公共交通機関の停留所間の移動時間、地域間の人口流動など、多様な空間的関係が存在します。更に街路ネットワークについても近年活発に整備されているOverture Mapsなどの非OSMデータも存在感を増しつつあります。これらの多様なソースからの地理的な離散関係を統一的に(異種)グラフ表現に変換し、GNNで扱えるようにすることは、都市研究・実務的な実装において非常に有用と考えています。
しかし、実際には地理空間データからグラフを構築するプロセスは煩雑です。GeoPandas、NetworkX、PyTorch Geometricといった異なるライブラリ間でのデータ変換や、ドメイン特有のグラフ構築ロジック(例:GTFSデータからの公共交通ネットワーク生成)の実装には、多くの時間とコードが必要になります。特に、異種グラフ(Heterogeneous Graph)を扱う場合、複数のノードタイプとエッジタイプを適切に管理する必要があり、さらに複雑さが増します。
このような課題を解決するために、city2graphというPythonライブラリを開発してみました。
ライブラリの概要

city2graphは、地理空間データをグラフ表現に変換し、グラフニューラルネットワークでの利用を可能にするPythonライブラリです。GeoPandas、NetworkX、PyTorch Geometricを統合したインターフェースを提供し、都市形態、公共交通、モビリティ(および人口移動)など、複数のドメインに対応しています。
こんなニッチなライブラリですがLinkedIn上の研究者・DS界隈から浸透し始めて、半年で世界中から280 GitHub Starsを付けて頂きました!
インストールはpipとcondaに対応しています。標準的なインストールではPyTorch Geometricを利用しない依存関係のみ採用されますが、pipではハードウェアの指定に応じて拡張された状態で必要なライブラリを一括でインストールが出来ます。(condaは公式ではPyTorchがサポートを終了したため、conda-forgeからマニュアルでのインストールが必要です)
基本的なインストール(PyTorchなし)
# pipの場合
pip install city2graph
# condaの場合
conda install city2graph -c conda-forge
PyTorch Geometric機能を含むインストール(pip)
# CPU版
pip install "city2graph[cpu]"
# GPU版(CUDA 12.8の例)
pip install "city2graph[cu128]"
PyTorch Geometric機能を含むインストール(conda)
# city2graphをインストール
conda install city2graph -c conda-forge
# PyTorchとPyTorch Geometricをインストール(CUDA 12.8の場合)
conda install -c conda-forge pytorch=2.7.1=*cuda128*
conda install -c conda-forge pytorch_geometric
基本的な使い方
city2graphの核となる機能は、GeoPandas、NetworkX、PyTorch Geometricの間でデータを相互変換できる点です。ここでは、1,000個のランダムポイントからk近傍グラフを作成する例を通してライブラリで共通のインターフェイスを示します。
データの準備
まず1,000個のランダムポイントを生成します:
import geopandas as gpd
from shapely import Point
import numpy as np
import city2graph as c2g
# 1) ランダムポイントを生成(EPSG:3857でメートル単位の距離計算)
np.random.seed(42)  # 再現性のため
num_points = 1000
x_coords = np.random.rand(num_points) * 1000  # 0〜1000のランダムなx座標
y_coords = np.random.rand(num_points) * 1000  # 0〜1000のランダムなy座標
gdf = gpd.GeoDataFrame(
    {"name": [f"Point_{i}" for i in range(num_points)]},
    geometry=[Point(x, y) for x, y in zip(x_coords, y_coords)],
    crs="EPSG:3857"
)
近接性グラフの作成
次に、k=10のKNNグラフを作成します:
# 2) 近接性グラフを作成(KNN法)
nodes, edges = c2g.knn_graph(
    gdf,
    distance_metric="euclidean",
    k=10,
    as_nx=False
)

これで、ノードとエッジの2つのGeoDataFrameが得られます。edgesのMultiIndexはnodesのIndexに対応しており、元のデータ列(geometryを含む)はそのまま保持されます。
フォーマット間の変換
このGeoDataFrameを、NetworkXやPyTorch Geometricに変換できます:
# 3) お好みのフレームワークに変換
# NetworkX
nx_graph = c2g.gdf_to_nx(nodes, edges)
# PyTorch Geometric
pyg_graph = c2g.gdf_to_pyg(nodes, edges)
このように、city2graphではGeoPandas、NetworkX、PyTorch Geometricの間を自由に行き来できます。各フォーマットの利点を活かしながら、データの前処理からGNNモデルの学習、結果の可視化まで、シームレスに実行できます。
異種グラフの構築
これまでの例では、すべてのノードが同じタイプの同種グラフ(Homogeneous Graph)を扱ってきました。ここで、複数のノードタイプとエッジタイプを持つ異種グラフ(Heterogeneous Graph)を用いることで、より細かなニュアンスの空間的関係を表現できるようになります。
異種グラフでは、ノードとエッジを辞書形式で管理します。例として、施設(amenity)と街路セグメント(segment)から異種グラフを構築してみましょう:
import city2graph as c2g
# 1. 施設データから近接性グラフを作成
amenity_nodes, amenity_edges = c2g.knn_graph(amenity_gdf, k=5, as_nx=False)
# 2. 街路セグメントのグラフを作成
segment_nodes, segment_edges = c2g.gdf_to_nx(segments_gdf)
segment_nodes, segment_edges = c2g.nx_to_gdf(segment_graph)
# 3. 施設と街路セグメントを近接性で接続
amenity_segment_nodes, amenity_segment_edges = c2g.bridge_nodes(
    {'amenity': amenity_nodes, 'segment': segment_nodes},
    proximity_method='knn',
    k=3
)
# 4. 異種グラフを構築(ノードとエッジを辞書形式で統合)
combined_nodes = {
    'amenity': amenity_segment_nodes['amenity'],
    'segment': amenity_segment_nodes['segment']
}
combined_edges = {
    ('segment', 'connects', 'segment'): segment_edges,
    ('amenity', 'is_nearby', 'segment'): amenity_segment_edges[('amenity', 'is_nearby', 'segment')]
}
# PyTorch GeometricのHeteroDataに変換
hetero_data = c2g.gdf_to_pyg(combined_nodes, combined_edges)
このように、city2graphでは異なるドメインのグラフを辞書形式で統合し、複雑な都市空間の関係性を一つの異種グラフとして表現できます。この例で構築した異種グラフは、次に説明するメタパスの基盤となります。
メタパスによる異種グラフの拡張
異種グラフでは、ノードやエッジに複数のタイプが存在します。例えば、都市空間では「amenity(施設)」と「segment(街路セグメント)」という2つのノードタイプが存在し、それらの間に「is_nearby(近接している)」や「connects(接続している)」といったエッジタイプが定義できます。
city2graphでは、メタパスを定義することで、異種グラフに新しいエッジを追加できます。メタパスとは、複数のノードタイプとエッジタイプを経由する経路のことです。例えば、「amenity → segment → segment → amenity」というメタパスは、街路ネットワークを経由して接続された施設間の関係を表現します:
import city2graph as c2g
# メタパスを定義:amenity → segment → segment → amenity(3ホップの経路)
metapaths = [[("amenity", "is_nearby", "segment"),
              ("segment", "connects", "segment"),
              ("segment", "connects", "segment"),
              ("segment", "is_nearby", "amenity")]]
# メタパスに基づいて、街路ネットワークを経由して施設を接続するエッジを追加
nodes_with_metapaths, edges_with_metapaths = c2g.add_metapaths(
    (combined_nodes, combined_edges),
    metapaths,
    edge_attr="distance_m",
    edge_attr_agg="sum"
)
この例では、2つの街路セグメントを経由して接続された施設間にエッジを追加しています。edge_attr="distance_m"とedge_attr_agg="sum"を指定することで、メタパス上の距離を合計して、新しいエッジの重みとして利用できます。
以下はsegmentのホップ数をインクリメンタルに追加した時のメタパスのアニメーションです:

メタパスを活用することで、直接接続されていないノード間の間接的な関係性を明示的にモデル化でき、GNNの表現力を向上させることができます。
構築可能なグラフの例
データソースやドメインの組み合わせ次第で構築可能なグラフは様々ですが、ここでは一例を紹介します。
1. 形態学的グラフ(Morphological Graph)
建物、街路、土地利用などのデータから、都市形態を表現するグラフを構築できます。OpenStreetMap(OSM)やOverture Mapsなどのオープンデータに対応しており、以下の3種類のエッジを含む異種グラフを生成できます:
- Private-to-private: 建物間の隣接関係
 - Public-to-public: 街路ネットワークの接続関係
 - Private-to-public: 建物と街路の接続関係
 
import city2graph as c2g
# 建物と街路のデータから形態学的グラフを生成
morph_g = c2g.morphological_graph(
    buildings_gdf, 
    streets_gdf, 
    building_key="bid", 
    street_key="sid"
)

2. 交通ネットワーク(Transportation Network)
GTFS(General Transit Feed Specification)形式の公共交通データから、停留所間の移動時間や接続関係を考慮したグラフを生成できます。バス、路面電車、鉄道など、複数のモードに対応しています。
import city2graph as c2g
# GTFSデータを読み込み
gtfs = c2g.load_gtfs("./gtfs_feed/")
# 移動時間を重みとした交通ネットワークグラフを生成
transit_g = c2g.travel_summary_graph(
    gtfs, 
    weight="mean_travel_time"
)

3. 近接性グラフ(Proximity Graph)
POI(Point of Interest)や建物などの地点データから、空間的な近接性に基づくエッジを生成できます。k近傍法(KNN)、Delaunay三角分割、Waxmanモデルなど、複数の手法に対応しています。これらはユークリッド距離だけでなく、マンハッタン距離や指定したネットワークデータに基づくネットワーク距離での計算も可能です。
import city2graph as c2g
# マンハッタン距離(L1ノルム)を使用したWaxmanグラフ
wax_l1_nodes, wax_l1_edges = c2g.waxman_graph(
    poi_gdf,
    distance_metric="manhattan",
    r0=100,
    beta=0.5
)
# ユークリッド距離(L2ノルム)を使用したWaxmanグラフ
wax_l2_nodes, wax_l2_edges = c2g.waxman_graph(
    poi_gdf,
    distance_metric="euclidean",
    r0=100,
    beta=0.5
)
# ネットワーク距離を使用したWaxmanグラフ
wax_net_nodes, wax_net_edges = c2g.waxman_graph(
    poi_gdf,
    distance_metric="network",
    r0=100,
    beta=0.5,
    network_gdf=segments_gdf.to_crs(epsg=6677)
)

4. モビリティグラフ(Mobility Graph)
OD(Origin-Destination)行列から、地域間の移動フローを表現するグラフを構築できます。バイクシェアリング、人口移動、歩行者フローなどのデータに対応しています。
import city2graph as c2g
# エッジリスト形式のODデータからグラフを生成
nodes_gdf, edges_gdf = c2g.od_matrix_to_graph(
    od_edges_df,
    zones_gdf,
    zone_id_col="zone_id",
    matrix_type="edgelist",
    source_col="origin",
    target_col="destination",
    weight_cols=["flow"]
)

5. PyTorch Geometricへの変換
前述の通り、生成したグラフはGNNフレームワークであるPyTorch GeometricのDataまたはHeteroData形式に簡単に変換できます。また、逆方向の変換(PyG → GeoDataFrame)もサポートしているため、予測結果を地図上で可視化することも可能です。
import city2graph as c2g
# GeoDataFrameをPyTorch Geometricに変換
data = c2g.gdf_to_pyg(buildings_gdf, edges_gdf)
# モデルで予測を実行(省略)
# 予測結果をGeoDataFrameに戻す
pred_gdf = c2g.pyg_to_gdf(data)
エンドツーエンドの例
import torch
from torch_geometric.nn import HeteroConv, GCNConv, Linear
import city2graph as c2g
# 1. 建物データから形態学的グラフを作成
morph_nodes, morph_edges = c2g.morphological_graph(
    buildings_gdf, 
    streets_gdf,
    building_key="bid",
    street_key="sid"
)
# 2. POIデータから近接性グラフを作成
poi_nodes, poi_edges = c2g.knn_graph(poi_gdf, k=8, as_nx=False)
# 3. 異種グラフを構築(ノードとエッジを辞書形式で統合)
combined_nodes = {
    'building': morph_nodes['building'],
    'street': morph_nodes['street'],
    'poi': poi_nodes
}
combined_edges = {
    ('building', 'adjacent_to', 'building'): morph_edges[('building', 'adjacent_to', 'building')],
    ('street', 'connects', 'street'): morph_edges[('street', 'connects', 'street')],
    ('building', 'faces', 'street'): morph_edges[('building', 'faces', 'street')],
    ('poi', 'near', 'poi'): poi_edges
}
# 4. PyTorch Geometricの異種グラフに変換
hetero_data = c2g.gdf_to_pyg(combined_nodes, combined_edges)
# 5. 異種グラフGNNモデルを定義
class HeteroGNN(torch.nn.Module):
    def __init__(self, hidden_dim=64, out_dim=1):
        super().__init__()
        
        # 各ノードタイプの入力埋め込み
        self.lin_building = Linear(-1, hidden_dim)
        self.lin_street = Linear(-1, hidden_dim)
        self.lin_poi = Linear(-1, hidden_dim)
        
        # 異種グラフ畳み込み層
        self.conv1 = HeteroConv({
            ('building', 'adjacent_to', 'building'): GCNConv(-1, hidden_dim),
            ('street', 'connects', 'street'): GCNConv(-1, hidden_dim),
            ('building', 'faces', 'street'): GCNConv((-1, -1), hidden_dim),
            ('poi', 'near', 'poi'): GCNConv(-1, hidden_dim),
        }, aggr='sum')
        
        self.conv2 = HeteroConv({
            ('building', 'adjacent_to', 'building'): GCNConv(-1, hidden_dim),
            ('street', 'connects', 'street'): GCNConv(-1, hidden_dim),
            ('building', 'faces', 'street'): GCNConv((-1, -1), hidden_dim),
            ('poi', 'near', 'poi'): GCNConv(-1, hidden_dim),
        }, aggr='sum')
        
        # 出力層(建物ノードの予測用)
        self.lin_out = Linear(hidden_dim, out_dim)
    
    def forward(self, x_dict, edge_index_dict):
        # 入力埋め込み
        x_dict = {
            'building': self.lin_building(x_dict['building']).relu(),
            'street': self.lin_street(x_dict['street']).relu(),
            'poi': self.lin_poi(x_dict['poi']).relu(),
        }
        
        # 異種グラフ畳み込み
        x_dict = self.conv1(x_dict, edge_index_dict)
        x_dict = {key: x.relu() for key, x in x_dict.items()}
        x_dict = self.conv2(x_dict, edge_index_dict)
        
        # 建物ノードの出力
        return self.lin_out(x_dict['building'])
# 6. モデルの学習
model = HeteroGNN(hidden_dim=64, out_dim=1)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(100):
    model.train()
    [optimizer.zero](http://optimizer.zero)_grad()
    
    # 順伝播
    out = model(hetero_data.x_dict, hetero_data.edge_index_dict)
    
    # 損失計算(例:建物の価格予測)
    loss = torch.nn.functional.mse_loss(out, hetero_data['building'].y)
    
    # 逆伝播
    loss.backward()
    optimizer.step()
    
    if epoch % 10 == 0:
        print(f'Epoch {epoch}, Loss: {loss.item():.4f}')
さいごに
少しでも面白いと思いましたら、ぜひGitHubのレポジトリにStar⭐️をお願い致します!
また、OSS開発ですのでプルリク等大歓迎でございます!
Discussion