GeoPandas

GeoPandas 是一个开源项目,旨在让使用 python 处理地理空间数据变得更容易。GeoPandas 扩展了 pandas 使用的数据类型,允许对几何类型数据进行操作。其中相关的几何操作由 shapely 执行。此外,Geopandas 还依赖 fiona 进行文件访问,依赖 matplotlib 进行绘图。

Shapely 是基于笛卡尔坐标的几何对象操作和分析的Python库,底层基于GEOSJTS库。Shapely不关心数据格式或坐标系,但可以很容易地与这些文件包集成。

文件读写

假设你有一个包含数据和几何图形的文件(如 GeoPackage、GeoJSON、Shapefile),您可以使用 geopandas.read_file() 读取,GeoPandas会自动检测文件类型并创建一个 GeoDataFrame

例如本文中我们使用 nybb 数据集,它含有纽约各区的地图,可通过 geodatasets 库获取,使用 geodatasets.get_path() 下载数据集,并获取本地副本的路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import geopandas
from geodatasets import get_path
import matplotlib.pyplot as plt

# 下载数据集/获取数据集的本地路径
path_to_data = get_path("nybb")

# 读取数据
gdf = geopandas.read_file(path_to_data)

# ...

# 写入文件,默认文件格式为Shapefile,可通过 driver 参数修改
# 需要注意的是,如果不指定编码的话,导出的数据中文将会是乱码
gdf.to_file("my_file.geojson", driver="GeoJSON",encoding="utf8")

Excel 文件

基本操作

计算面积

可以通过 GeoDataFrame.area 属性得到每个多边形(polygon or MultiPolygon)的面积,返回 pandas.Series 数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 设置索引,便于直观显示面积大小
gdf = gdf.set_index("BoroName")

gdf["area"] = gdf.area
print(gdf["area"])

# 输出如下
'''
BoroName
Staten Island 1.623822e+09
Queens 3.045214e+09
Brooklyn 1.937478e+09
Manhattan 6.364712e+08
Bronx 1.186926e+09
Name: area, dtype: float64
'''

边界、质心与距离

利用 GeoDataFrame.boundary 属性获取每个图形的边界

1
2
3
4
5
6
7
8
9
10
11
12
13
gdf["boundary"] = gdf.boundary
gdf["boundary"]

# 输出如下:
'''
BoroName
Staten Island MULTILINESTRING ((970217.022 145643.332, 97022...
Queens MULTILINESTRING ((1029606.077 156073.814, 1029...
Brooklyn MULTILINESTRING ((1021176.479 151374.797, 1021...
Manhattan MULTILINESTRING ((981219.056 188655.316, 98094...
Bronx MULTILINESTRING ((1012821.806 229228.265, 1012...
Name: boundary, dtype: geometry
'''

值得注意的是,当我们把边界也保存为一个新的一列(column) 时,同一个 GeoDataFrame 中就存在了两个 column,一个 GeoDataFrame 只有一个 column 处于激活(active)状态,激活的 column 可以用于绘制。

通过 GeoDataFrame.centroid 获取每个图形的质心

1
2
3
4
5
6
7
8
9
10
11
12
13
gdf["centroid"] = gdf.centroid
gdf["centroid"]

# 输出如下:
'''
BoroName
Staten Island POINT (941639.450 150931.991)
Queens POINT (1034578.078 197116.604)
Brooklyn POINT (998769.115 174169.761)
Manhattan POINT (993336.965 222451.437)
Bronx POINT (1021174.790 249937.980)
Name: centroid, dtype: geometry
'''

通过调用 GeoDataFrame.distance() 计算质心到指定点的距离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
first_point = gdf["centroid"].iloc[0] #以第一行的点为例
gdf["distance"] = gdf["centroid"].distance(first_point)
gdf["distance"]

# 输出如下:
'''
BoroName
Staten Island 0.000000
Queens 103781.535276
Brooklyn 61674.893421
Manhattan 88247.742789
Bronx 126996.283623
Name: distance, dtype: float64
'''

因为 geopandas.GeoDataFrame 是数据类型 pandas.DataFrame 的子类,所以其相关属性和方法同样适用于这里。比如我们来计算上面得到的所有距离的平均值

1
2
3
gdf["distance"].mean()

# 输出:76140.09102166798

判断点面关系

1
one_geometry.contains(Point([X,Y])

如果要批量判断由点构成的 GeoDataFrame 和由几何体构成的 GeoDataFrame 之间的点面关系(点是否在面的内部),参考本文【空间连接-点面关系Plus】一节。

绘制地图

GeoPandas 自然还可以绘制地图,从而使我们可以轻松查看地图在画布上效果。调用 GeoDataFrame.plot()。当需要按某一列的数值进行颜色编码时,则将该列作为第一个参数传入。
在下面的示例中,我们绘制了当前已激活的地图,并按 area 这一列进行了颜色编码,并显示图例(legend=True)。

1
2
3
4
5
6
7
8
#普通单色图
gdf.plot()

# 按列 "area" 进行颜色编码,并展示图例
gdf.plot("area", legend=True)

# 地图探索:返回交互式的地图
gdf.explore("area", legend=False)

变更激活地图

如果同一个 GeoDataFrame 数据内含有多个 geometry 属性的数据,我们可以通过 GeoDataFrame.set_geometry()指定所选择的那一列作为新的激活地图(active geometry),然后绘制它。

1
2
gdf = gdf.set_geometry("centroid")
gdf.plot("area", legend=True)

图案叠加

当然我们还可以将两个 GeoSeries 叠加在一起,只需将其中一个画布作为 axis 即可。

1
2
ax = gdf["geometry"].plot()
gdf["centroid"].plot(ax=ax, color="black")

网格图例

Choro legends — GeoPandas 0.14.0+0.g0eb2a5e.dirty documentation

背景底图

本示例将展示如何在使用 .plot() 方法创建的绘图上面添加背景底图。
该示例需要利用 contextily 库,它可以从多个来源(OpenStreetMap、CartoDB)来检索网络地图瓦片。可以查看 contextily 的介绍指南了解更多新功能。

1
2
3
4
5
6
import contextily as cx
gdf.plot("area", legend=True)
cx.add_basemap(ax) #此处添加底图
cx.add_basemap(ax, crs=df.crs) #指定投影坐标系
cx.add_basemap(ax, zoom=12) #设置细节水平
cx.add_basemap(ax, source=cx.providers.CartoDB.Positron) #选取不同来源

创建几何体

凸包

我们可以通过 GeoDataFrame.convex_hull 获取给定几何图形的凸包/凸多边形(convex hull):

1
2
3
4
5
6
7
8
gdf["convex_hull"] = gdf.convex_hull

# 设置透明度为 0.5,将凸包的图形(作为背景图)与边界图重叠
ax = gdf["convex_hull"].plot(alpha=0.5)
# passing the first plot and setting linewidth to 0.5
gdf["boundary"].plot(ax=ax, color="white", linewidth=0.5)

plt.show()

缓冲版本

在某些情况下,我们可能需要使用 GeoDataFrame.buffer() 对几何体进行缓冲,该方法自动应用于已激活的几何体,当然我们也可以将它们直接应用于任何 GeoSeries

此处的“缓冲”意味着对图形进行类似“膨胀”的处理,输入参数为膨胀直径

1
2
3
4
5
6
7
8
9
10
# buffering the active geometry by 10 000 feet (geometry is already in feet)
gdf["buffered"] = gdf.buffer(10000)

# buffering the centroid geometry by 10 000 feet (geometry is already in feet)
gdf["buffered_centroid"] = gdf["centroid"].buffer(10000)


ax = gdf["buffered"].plot(alpha=0.5)
gdf["buffered_centroid"].plot(ax=ax, color="red", alpha=0.5)
gdf["boundary"].plot(ax=ax, color="white", linewidth=0.5)

Voronoi 图

待更:Create a Python Voronoi Diagram with GeoPandas and Geoplot - wellsr.com

投影坐标系

每个 GeoSeries 都有自己的坐标参考系(Coordinate Reference System,CRS),可通过 GeoSeries.crs 访问,可以获知 GeoPandas 几何图形在地球表面的坐标位置

WGS84 的授权码为 EPSG:4326。这种 CRS 是地理坐标:坐标是经纬度。

前面我们一直在用的示例地图可以通过 gdf.crs 查看其 CRS,然后通过 gdf.to_crs() 方法更改坐标系:

1
2
3
4
print(gdf.crs)
print(gdf.crs)
boroughs_4326 = gdf.to_crs("EPSG:4326")
boroughs_4326.plot()

为了通过更丰富的坐标系绘制图像,我们还可以结合 CartoPy 库。

CartoPy 最初是在英国气象局开发的,目的是让科学家能够快速、方便、最重要的是准确地在地图上可视化他们的数据。它主要特点是面向对象的投影定义,以及在投影之间转换点、线、向量、多边形和图像的能力。

一个简单的示例如下,利用各种不同的投影来绘制世界地图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cartopy.crs as ccrs
import matplotlib.pyplot as plt

# 选取多种投影
projections = [
ccrs.PlateCarree(),
ccrs.Robinson(),
ccrs.Mercator(),
ccrs.Orthographic()
]

# 画出多子图
fig = plt.figure()
for i, proj in enumerate(projections, 1):
ax = fig.add_subplot(2, 2, i, projection=proj)
ax.stock_img() # 添加低分辨率的地形图
ax.coastlines() # 添加海岸线
ax.set_title(f'{type(proj)}', fontsize='small')

plt.show()
  • 使用 CartoPy 的参考系给 GeoPandas 绘制图像:
1
2
3
4
5
6
7
8
9
10
11
12
13
# 读取/下载 世界地图
path = get_path("naturalearth.land")
gdf = geopandas.read_file(path)

# 定义 CartoPy CRS 对象
crs = ccrs.AzimuthalEquidistant()

# CRS的字符串或字典在 `.proj4_init` 中获得并传递给 GeoPandas
crs_proj4 = crs.proj4_init
gdf = gdf.to_crs(crs_proj4)

# 用 GeoPandas 的方法绘制
gdf.plot()
  • 使用 GeoPandas 的数据对象给 CartoPy 绘图:
1
2
3
4
5
fig = plt.figure()
ax = fig.add_subplot(111, projection=crs)
ax.add_geometries(gdf["geometry"], crs=crs) # Key line

plt.show()

自建GeoDataFrame

经纬度数据

一个 GeoDataFrame 在 DataFrame 数据的基础上还需要一个 shapely 对象。

一般地,我们通过 geopandas.points_from_xy() 将给定的经纬度( Longitude 和 Latitude)转换为 shapely.Point 对象列表,然后在创建GeoDataFrame时将其作为一个 geometry

1
2
3
points_from_xy(df.Longitude, df.Latitude)
# 等价于
[Point(x, y) for x, y in zip(df.Longitude, df.Latitude)]

在创建GeoDataFrame时还需要注意必须指定 crs 来正确解释数据所使用的参考系!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 以南美洲为例
df = pd.DataFrame(
{
"City": ["Buenos Aires", "Brasilia", "Santiago", "Bogota", "Caracas"],
"Country": ["Argentina", "Brazil", "Chile", "Colombia", "Venezuela"],
"Latitude": [-34.58, -15.78, -33.45, 4.60, 10.48],
"Longitude": [-58.66, -47.91, -70.66, -74.08, -66.86],
}
)

gdf = geopandas.GeoDataFrame(df,
geometry=geopandas.points_from_xy(df.Longitude, df.Latitude),
crs="EPSG:4326"
)
print(gdf.head())


world = geopandas.read_file(get_path("naturalearth.land"))
# 在世界地图上取出南美洲
ax = world.clip([-90, -55, -25, 15]).plot(color="white", edgecolor="black")
# 将自己的 gdf 绘制处理
gdf.plot(ax=ax, color="red")

plt.show()

此外还可以使用 WTK字符串 (Well-known Text)建立数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
df = pd.DataFrame(
{
"City": ["Buenos Aires", "Brasilia", "Santiago", "Bogota", "Caracas"],
"Country": ["Argentina", "Brazil", "Chile", "Colombia", "Venezuela"],
"Coordinates": [
"POINT(-58.66 -34.58)",
"POINT(-47.91 -15.78)",
"POINT(-70.66 -33.45)",
"POINT(-74.08 4.60)",
"POINT(-66.86 10.48)",
],
}
)

from shapely import wkt

df["Coordinates"] = geopandas.GeoSeries.from_wkt(df["Coordinates"])
gdf = geopandas.GeoDataFrame(df, geometry="Coordinates")

更多 WTK 描述几何对象的示例:WKT 描述的几何对象 — OGC标准规范 1.0 文档

空间栅格图像

Using GeoPandas with Rasterio to sample point data — GeoPandas 0.14.0+0.g0eb2a5e.dirty documentation

空间连接

空间连接(spatial join)使用二元谓词(如相交 intersects 和交叉 crosses),根据两个 GeoDataFrames 几何图形之间的空间关系将它们组合在一起。

常见的用例一般是点图层和多边形图层之间的空间连接,在这种情况下,需要保留点的几何图形并抓取相交多边形的属性。

1
2
3
4
# 示例变量

pointdf #点数据
polydf #多边形数据

Left/Right outer join

table1table2 进行左连接Left Outer Join 时,以左为主。结果以 table1 为主,关联上 table2 的数据,显示左边的所有数据,然后右边显示的是和左边有交集部分的数据(若有多个关联,右表可重复)。右连接 Right Outer Join 类似。

在 Spatial Join 中,交集主要是指几何图形 geometry 的几何关系。

1
2
3
4
5
6
join_left_df = pointdf.sjoin(polydf, how="left")
join_left_df
# 数据对象中的 NaNs 表示 point 不在 poly 内部

join_right_df = pointdf.sjoin(polydf, how="right")
join_right_df

Inner join

1
2
3
join_inner_df = pointdf.sjoin(polydf, how="inner")
join_inner_df
# 不含NaNs项; 将所有不含有 intersect 关系的条目删除

判断点面关系Plus

我们可以利用空间关系结合 pandas.DataFrame.value_count() 批量对由点构成的 GeoDataFrame 和由几何体构成的 GeoDataFrame 之间的点面关系进行统计

1
2
result = gpd.sjoin(pointdf, polydf, predicate='within') #这会drop掉NaNs项
pd.DataFrame(result["index_right"].value_counts())

PyTorch Geo

PyTorch Geometric Library (简称 PyG) 是一个基于 PyTorch 的图神经网络库。它的运行速度很快,训练模型速度可以达到DGL(Deep Graph Library )v0.2 的40倍(数据来自论文)。除了出色的运行速度外,PyG中也集成了很多论文中提出的方法(GCN,SGC,GAT,SAGE等)和常用数据集。因此对于复现论文来说也相当方便。

图结构数据受理

PyG中通过 torch_geometric.data模块下的 Data类来接收并管理 Graph 数据。它包含 5 个属性,每一个属性都不是必须的,可以为空。

  • Data.x:存储每个节点及其特征,形状是[num_nodes, num_node_features]
  • Data.edge_index:存储节点之间的边,形状是 [2, num_edges]
  • Data.pos:存储节点的坐标,形状是[num_nodes, num_dimensions]
  • Data.y:存储样本标签。如果是每个节点都有标签,那么形状是[num_nodes, *];如果是整张图只有一个标签,那么形状是[1, *]
  • Data.edge_attr: 存储边的特征,形状是[num_edges, num_edge_features]

实际上,Data对象不仅仅限制于这些属性,我们可以通过data.face来扩展Data,以形状是 [3, num_faces]torch.long 类型的张量来保存 3D mesh 中三角形的连接性。

下面给出一个构建PyG中的图结构数据的示例(带有三个节点的无权无向图):

1
2
3
4
5
6
7
8
import torch
from torch_geometric.data import Data

edge_index = torch.tensor([[0, 1, 1, 2],
[1, 0, 2, 1]], dtype=torch.long)
x = torch.tensor([[-1], [0], [1]], dtype=torch.float)

data = Data(x=x, edge_index=edge_index)

它对应的图如下:

值得注意的是,edge_index中边的存储方式是给定两个list,第 1 个list是所有边的起始点,第 2 个list是所有边的目标节点。而不是由一个个 起点-终点对 给出的。如果想用后者的存储方式,可以对其转置调用 contiguous() 方法:

1
2
3
4
5
6
7
8
9
10
import torch
from torch_geometric.data import Data

edge_index = torch.tensor([[0, 1],
[1, 0],
[1, 2],
[2, 1]], dtype=torch.long)
x = torch.tensor([[-1], [0], [1]], dtype=torch.float)

data = Data(x=x, edge_index=edge_index.t().contiguous())

此外,尽管该图只有两条边,但我们仍然需要定义4个索引元组来说明边的两个方向。而且 edge_index 中的元素必须只包含范围为 {0, ..., num_nodes - 1} 的索引。
这是必要的,因为我们希望最终的数据表示尽可能紧凑,例如,我们希望通过 x[0]x[1] 分别索引第一条边 (0, 1) 的源节点和目的节点特征。

可以通过运行 validate() 来检查最终的数据对象是否满足这些要求:

1
data.validate(raise_on_error=True)

其他操作还有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
print(data.keys())
>>> ['x', 'edge_index']

print(data['x'])
>>> tensor([[-1.0],
[0.0],
[1.0]])

for key, item in data:
print(f'{key} found in data')

>>> x found in data
>>> edge_index found in data

'edge_attr' in data
>>> False

data.num_nodes
>>> 3

data.num_edges
>>> 4

data.num_node_features
>>> 1

data.has_isolated_nodes() #离群点
>>> False

data.has_self_loops() # 自环
>>> False

data.is_directed() #是否为有向图
>>> False

# 转换为 GPU 对象
device = torch.device('cuda')
data = data.to(device)

公开数据集

PyG 的 Dataset继承自torch.utils.data.Dataset,并且它也自带了很多公开的图数据集。(就像视觉领域的手写数字识别数据集那样)

TUDataset为例,通过下列代码就可以加载数据集,root参数设置数据下载的位置。通过索引可以访问每一个数据。

1
2
3
4
5
from torch_geometric.datasets import TUDataset
dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES')
data = dataset[0]

>>> Data(edge_index=[2, 168], x=[37, 3], y=[1])

从输出结果可以看出,数据集中的第一张图(dataset[0])含有 37 个节点,每个节点有 3 个特征,一共有 168/2 = 84 条边(无向图)。并且,整个这张图只有一个标签,也表明了整个数据集的分类目标是 图级别的(graph-level)。

我们可以用 Python 的切片(slices)方法进行数据集划分。还可以用 PyG 提供的 shuffle() 方法打乱顺序。

1
2
3
4
dataset = dataset.shuffle()
# 等价于
perm = torch.randperm(len(dataset))
dataset = dataset[perm]

接下来看看另一个数据集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from torch_geometric.datasets import Planetoid

dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
>>> Data(edge_index=[2, 10556], test_mask=[2708],
train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])

data.is_undirected()
>>> True

data.train_mask.sum().item()
>>> 140

data.val_mask.sum().item()
>>> 500

data.test_mask.sum().item()
>>> 1000

数据集中的train_maskval_mask 和 test_mask 是训练时划分使用的掩码。比如训练集中,要将节点 i 加入训练集,那么 train_mask[i] 就取 0

所以在这里的数据集中,决定了将 140 个节点参加训练;同理,将 1000个节点划为测试集;将500个节点用于验证。

更多自带可用的公开数据集:torch_geometric.datasets — pytorch_geometric documentation

自定义数据集

尽管 PyG 已经包含许多有用的数据集,我们也可以通过继承torch_geometric.data.Dataset使用自己的数据集。PyG 提供 2 种不同的Dataset

  • InMemoryDataset:使用这个Dataset会一次性把数据全部加载到内存中。
  • Dataset:使用这个Dataset每次加载一个数据到内存中,比较常用。

我们需要在自定义的Dataset的初始化方法中传入数据存放的路径,然后 PyG 会在这个路径下再划分 2 个文件夹:

  • raw_dir: 存放原始数据的路径,一般是 csv、mat 等格式
  • processed_dir: 存放处理后的数据,一般是 pt 格式 ( 由我们重写process()方法实现)。

在 PyTorch 中,是没有这两个文件夹的。下面来说明一下这两个文件夹在 PyG 中的实际意义和处理逻辑。

待更

小批量样本

PyG 也重写了自己的 Loader 用于批量读取图数据。

1
2
3
4
5
6
7
8
9
10
11
12
from torch_geometric.datasets import TUDataset
from torch_geometric.loader import DataLoader

dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES', use_node_attr=True)
loader = DataLoader(dataset, batch_size=32, shuffle=True)

for data in loader:
data
>>> DataBatch(batch=[1082], edge_index=[2, 4066], x=[1082, 21], y=[32])

data.num_graphs
>>> 32

此外,还增加了一个新的属性 torch_geometric.data.Batch 继承自 torch_geometric.data.Data

batch is a column vector which maps each node to its respective graph in the batch:

You can use it to, e.g., average node features in the node dimension for each graph individually:

数据变换与增强

1
2
3
4
5
6
7
8
import torch_geometric.transforms as T
from torch_geometric.datasets import ShapeNet

dataset = ShapeNet(root='/tmp/ShapeNet', categories=['Airplane'],
pre_transform=T.KNNGraph(k=6))

dataset[0]
>>> Data(edge_index=[2, 15108], pos=[2518, 3], y=[2518])

通过 pre_transform 在将 ShapNet 数据读入之前先对其进行 KNN 聚类形成新的图。

1
2
3
4
5
6
7
8
9
import torch_geometric.transforms as T
from torch_geometric.datasets import ShapeNet

dataset = ShapeNet(root='/tmp/ShapeNet', categories=['Airplane'],
pre_transform=T.KNNGraph(k=6),
transform=T.RandomJitter(0.01))

dataset[0]
>>> Data(edge_index=[2, 15108], pos=[2518, 3], y=[2518])

通过 transform 对图数据进行随机的增强——在 0.01范围内随机变换节点坐标。

模型构建与训练

基础示例

此处展示了将简单的 GCN 模型 应用于 Cora citation 数据集实现分类任务的流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

from torch_geometric.datasets import Planetoid

import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

dataset = Planetoid(root='/tmp/Cora', name='Cora')

'''设计模型'''
class GCN(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = GCNConv(dataset.num_node_features, 16)
self.conv2 = GCNConv(16, dataset.num_classes)

def forward(self, data):
x, edge_index = data.x, data.edge_index

x = self.conv1(x, edge_index)
x = F.relu(x) # 用 ReLU 作为非线性激活函数
x = F.dropout(x, training=self.training)
x = self.conv2(x, edge_index)

return F.log_softmax(x, dim=1) #Softmax用于分类


'''模型训练'''
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GCN().to(device)
data = dataset[0].to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)

model.train()
for epoch in range(200):
optimizer.zero_grad()
out = model(data)
loss = F.nll_loss(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()


'''模型评估'''
model.eval()
pred = model(data).argmax(dim=1)
correct = (pred[data.test_mask] == data.y[data.test_mask]).sum()
acc = int(correct) / int(data.test_mask.sum())
print(f'Accuracy: {acc:.4f}')

内置网络层

GNN Cheatsheet — pytorch_geometric documentation

自定义网络

【图算法】构建消息传递网络教程 Creating Message Passing Networks by Pytorch-geometric - LeonYi - 博客园 (cnblogs.com)