半監督學習圖神經網絡節點分類實踐


參考https://andyguo.blog.csdn.net/article/details/117969648

一、為什么要在圖上進行神經網絡學習

在過去的深度學習應用中,我們接觸的數據形式主要是這四種:矩陣、張量、序列(sequence)和時間序列(time series)。然而來自現實世界應用的數據更多地是圖的結構,如社交網絡、交通網絡、蛋白質與蛋白質相互作用網絡、知識圖譜和大腦網絡等。圖提供了一種通用的數據表示方法,眾多其他類型的數據也可以轉化為圖的形式。

1.1、問題的分類

大量的現實世界的問題可以作為圖上的一組小的計算任務來解決。推斷節點屬性、檢測異常節點(如垃圾郵件發送者)、識別與疾病相關的基因、向病人推薦葯物等,都可以概括為節點分類問題。
推薦、葯物副作用預測、葯物與目標的相互作用識別和知識圖譜的完成等,本質上都是邊預測問題。同一圖的節點存在連接關系,這表明節點不是獨立的。

然而,傳統的機器學習技術假設樣本是獨立且同分布的,因此傳統機器學習方法不適用於圖計算任務。

圖機器學習研究如何構建節點表征,節點表征要求同時包含節點自身的信息和節點鄰接的信息,從而我們可以在節點表征上應用傳統的分類技術實現節點分類。圖機器學習成功的關鍵在於如何為節點構建表征。

二、將神經網絡應用於圖的挑戰

傳統的深度學習是為規則且結構化的數據設計的,圖像、文本、語音和時間序列等都是規則且結構化的數據。但圖是不規則的,節點是無序的,節點可以有不同的鄰居節點。
規則數據的結構信息是簡單的,而圖的結構信息是復雜的,特別是在考慮到各種類型的復雜圖,它們的節點和邊可以關聯豐富的信息,這些豐富的信息無法被傳統的深度學習方法捕獲。
圖深度學習是一個新興的研究領域,它將深度學習技術與圖數據連接起來,推動了現實中的圖預測應用的發展。然而,此研究領域也面臨着前所未有的挑戰。

以上內容整理自Deep Learning on Graphs: An Introduction

三、簡單圖論

3.1、圖的表示

先介紹幾種圖概念

1、同構圖:在圖里面,節點的類型和邊的類型只有一種的圖,舉個例子,像社交網絡中只存在一種節點類型,用戶節點和一種邊的類型,用戶-用戶之間的連邊。

2、異構圖:在圖里面,節點的類型+邊的類型>2的一種圖,舉個例子,論文引用網絡中,存在着作者節點和paper節點,邊的關系有作者-作者之間的共同創作關系連邊,作者-論文之間的從屬關系,論文-論文之間的引用關系。

3、屬性圖:圖的節點上存在着初始屬性attribute,可以用作后續節點的特征

4、動態圖:圖中的節點或者邊都是隨着時間變化的,可能增加或減少,一般是圖的構成是按照時間片來構成,每一個時間片一個圖的表示,例如t1時刻的圖是初始圖,t2時刻的圖就是節點或連邊變化后的圖一直到tn時刻

5、關系圖:圖表示了一種節點之間的隱含關系,舉個例子 知識圖譜

3.2、屬性

1、拉普拉斯矩陣, Laplacian Matrix

  • 給定一個圖 \(\mathcal{G}=\{\mathcal{V}, \mathcal{E}\}\), 其鄰接矩陣為 \(A\), 其拉普拉斯矩陣定義為 \(\mathbf{L}=\mathbf{D}-\mathbf{A}\), 其中

\[\mathbf{D}=\operatorname{diag}\left(\mathbf{d}\left(\mathbf{v}_{\mathbf{1}}\right), \cdots, \mathbf{d}\left(\mathbf{v}_{\mathbf{N}}\right)\right) \]

2、對稱歸一化的拉普拉斯矩陣, Symmetric normalized Laplacian

  • 給定一個圖 \(\mathcal{G}=\{\mathcal{V}, \mathcal{E}\}\), 其鄰接矩陣為 \(A\) ,其規范化的拉普拉斯矩陣定義為

\[\mathbf{L}=\mathbf{D}^{-\frac{1}{2}}(\mathbf{D}-\mathbf{A}) \mathbf{D}^{-\frac{1}{2}}=\mathbf{I}-\mathbf{D}^{-\frac{1}{2}} \mathbf{A} \mathbf{D}^{-\frac{1}{2}} \]

四、圖結構數據上的機器學習

  1. 節點預測:預測節點的類別或某類屬性的取值
    例子:對是否是潛在客戶分類、對游戲玩家的消費能力做預測
  2. 邊預測:預測兩個節點間是否存在鏈接
    例子:Knowledge graph completion、好友推薦、商品推薦
  3. 圖的預測:對不同的圖進行分類或預測圖的屬性
    例子:分子屬性預測
  4. 節點聚類:檢測節點是否形成一個社區
    例子:社交圈檢測

五、 消息傳遞范式介紹

https://andyguo.blog.csdn.net/article/details/118053685

下方圖片展示了基於消息傳遞范式的聚合鄰接節點信息來更新中心節點信息的過程:

gnn-gcn

圖中黃色方框部分展示的是一次鄰接節點信息傳遞到中心節點的過程:

1、B節點的鄰接節點(A,C)的信息經過變換后聚合到B節點,

2、接着B節點信息與鄰接節點聚合信息一起經過變換得到B節點的新的節點信息。

3、同時,分別如紅色和綠色方框部分所示,遵循同樣的過程,C、D節點的信息也被更新。實際上,同樣的過程在所有節點上都進行了一遍,所有節點的信息都更新了一遍。

4、這樣的“鄰接節點信息傳遞到中心節點的過程”會進行多次。如圖中藍色方框部分所示,A節點的鄰接節點(B,C,D)的已經發生過一次更新的節點信息,經過變換、聚合、再變換產生了A節點第二次更新的節點信息。

5、多次更新后的節點信息就作為節點表征。

5.1、公式描述

消息傳遞圖神經網絡遵循上述的“聚合鄰接節點信息來更新中心節點信息的過程”,來生成節點表征。

\(\mathbf{x}_{i}^{(k-1)} \in \mathbb{R}^{F}\) 表示 \((k-1)\) 層中節點 \(i\) 的節點表征, \(\mathbf{e}_{j, i} \in \mathbb{R}^{D}\) 表示從節點 \(j\) 到節點 \(i\) 的邊的屬性。

消息傳遞圖神經網絡可以描述為表示從節點 j到節點i的邊的屬性,消息傳遞圖神經網絡可以描述為:

\[\mathbf{x}_i^{(k)} = \gamma^{(k)} \left( \mathbf{x}_i^{(k-1)}, \square_{j \in \mathcal{N}(i)} \, \phi^{(k)}\left(\mathbf{x}_i^{(k-1)}, \mathbf{x}_j^{(k-1)},\mathbf{e}_{j,i}\right) \right), \]

其中 $\square $表示可微分的、具有排列不變性,函數輸出結果與輸入參數的排列無關的函數。

具有排列不變性的函數有,sum()函數、mean()函數和max()函數。
\(\gamma\)\(\phi\)表示可微分的函數,如MLPs(多層感知器)。

5.2、MessagePassing基類初步分析

Pytorch Geometric(PyG)提供了MessagePassing基類,它封裝了“消息傳遞”的運行流程。通過繼承MessagePassing基類,可以方便地構造消息傳遞圖神經網絡。

構造一個最簡單的消息傳遞圖神經網絡類,我們只需定義message()方法(\(\phi\))、update()方法( γ \gamma γ),以及使用的消息聚合方案(aggr="add"、aggr="mean"或aggr="max")。

5.2.1、對象初始化方法

MessagePassing(aggr="add", flow="source_to_target", node_dim=-2)

  • aggr:定義要使用的聚合方案(“add”、"mean "或 “max”);
  • flow:定義消息傳遞的流向("source_to_target "或 “target_to_source”);
  • node_dim:定義沿着哪個維度傳播,默認值為-2,也就是節點表征張量(Tensor)的哪一個維度是節點維度。節點表征張量x形狀為[num_nodes, num_features],其第0維度(也是第-2維度)是節點維度,其第1維度(也是第-1維度)是節點表征維度,所以我們可以設置node_dim=-2。
  • 注:MessagePassing(……)等同於MessagePassing.init(……)

5.3、舉例

我們以繼承 MessagePassing 基類的 GCNConv 類為例,學習如何通過繼承 MessagePassing 基類來實現一個 簡單的圖神經網絡。
GCNConv 的數學定義為

\[\mathbf{x}_{i}^{(k)}=\sum_{j \in \mathcal{N}(i) \cup\{i\}} \frac{1}{\sqrt{\operatorname{deg}(i)} \cdot \sqrt{\operatorname{deg}(j)}} \cdot\left(\boldsymbol{\Theta} \cdot \mathbf{x}_{j}^{(k-1)}\right) \]

其中,鄰接節點的表征 \(\mathbf{x}_{j}^{(k-1)}\) 首先通過與權重矩陣 \(\Theta\) 相乘進行變換,然后按端點的度 \(\operatorname{deg}(i), \operatorname{deg}(j)\) 進行 歸一化處理,最后進行求和。這個公式可以分為以下幾個步驟:

  1. 向鄰接矩陣添加自環邊。
  2. 對節點表征做線性轉換。
  3. 計算歸一化系數。
  4. 歸一化鄰接節點的節點表征。
  5. 將相鄰節點表征相加("求和 "聚合)。

步驟1-3通常是在消息傳遞發生之前計算的。
步驟4-5可以使用MessagePassing基類輕松處理。該層的全部實現如下所示。

import torch
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree

class GCNConv(MessagePassing):
    def __init__(self, in_channels, out_channels):
        super(GCNConv, self).__init__(aggr='add', flow='source_to_target')
        # "Add" aggregation (Step 5).
        # flow='source_to_target' 表示消息從源節點傳播到目標節點
        self.lin = torch.nn.Linear(in_channels, out_channels)

    def forward(self, x, edge_index):
        # x has shape [N, in_channels]
        # edge_index has shape [2, E]

        # Step 1: Add self-loops to the adjacency matrix.
        # 第一步:向鄰接矩陣添加自環邊
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))

        # Step 2: Linearly transform node feature matrix.
        # 第二步:對節點表征做線性轉換
        x = self.lin(x)

        # Step 3: Compute normalization.
        # 第三步:計算歸一化系數
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv_sqrt = deg.pow(-0.5)
        norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]

        # Step 4-5: Start propagating messages.
        # 第四五步:歸一化鄰接節點的節點表征;將相鄰節點表征相加("求和 "聚合)
        return self.propagate(edge_index, x=x, norm=norm)

    def message(self, x_j, norm):
        # x_j has shape [E, out_channels]
        # Step 4: Normalize node features.
        return norm.view(-1, 1) * x_j

六、半監督學習節點分類

6.1、任務

  • 學習實現多層圖神經網絡的方法
  • 並以節點分類任務為例,學習訓練圖神經網絡的一般過程
  • 我們將以Cora數據集為例子進行說明,Cora是一個論文引用網絡,節點代表論文,如果兩篇論文存在引用關系,則對應的兩個節點之間存在邊,各節點的屬性都是一個1433維的詞包特征向量.
  • 我們要做一些准備工作,即獲取並分析數據集、構建一個方法用於分析節點表征的分布。
  • 我們考察MLP神經網絡用於節點分類的表現,並觀察基於MLP神經網絡學習到的節點表征的分布。
  • 我們逐一介紹GCN, GAT這兩個圖神經網絡的理論、對比它們在節點分類任務中的表現以及它們學習到的節點表征的質量。
  • 我們比較三者在節點表征學習能力上的差異。
  1. GAT與GCN的聯系與區別

    • 無獨有偶,我們可以發現本質上而言:GCN與GAT都是將鄰居頂點的特征聚合到中心頂點上(一種aggregate運算),利用graph上的local stationary學習新的頂點特征表達。

    • 不同的是GCN利用了拉普拉斯矩陣,GAT利用attention系數。

    • 一定程度上而言,GAT會更強,因為 頂點特征之間的相關性被更好地融入到模型中。

  2. 為什么GAT適用於有向圖?

    我認為最根本的原因是GAT的運算方式是逐頂點的運算(node-wise),這一點可從公式(1)—公式(3)中很明顯地看出。每一次運算都需要循環遍歷圖上的所有頂點來完成。逐頂點運算意味着,擺脫了拉普利矩陣的束縛,使得有向圖問題迎刃而解。\

  3. 為什么GAT適用於inductive任務?

  4. GAT中重要的學習參數是 W 與 a(·) ,因為上述的逐頂點運算方式,這兩個參數僅與頂點特征相關,與圖的結構毫無關系。所以測試任務中改變圖的結構,對於GAT影響並不大,只需要改變 Ni,重新計算即可。

  5. 與此相反的是,GCN是一種全圖的計算方式,一次計算就更新全圖的節點特征。學習的參數很大程度與圖結構相關,這使得GCN在inductive任務上遇到困境。

6.2、准備數據

from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures

dataset = Planetoid(root='dataset', name='Cora', transform=NormalizeFeatures())

print()
print(f'Dataset: {dataset}:')
print('======================')
print(f'Number of graphs: {len(dataset)}')
print(f'Number of features: {dataset.num_features}')
print(f'Number of classes: {dataset.num_classes}')

data = dataset[0]  # Get the first graph object.

print()
print(data)
print('======================')

# Gather some statistics about the graph.
print(f'Number of nodes: {data.num_nodes}')
print(f'Number of edges: {data.num_edges}')
print(f'Average node degree: {data.num_edges / data.num_nodes:.2f}')
print(f'Number of training nodes: {data.train_mask.sum()}')
print(f'Training node label rate: {int(data.train_mask.sum()) / data.num_nodes:.2f}')
print(f'Contains isolated nodes: {data.contains_isolated_nodes()}')
print(f'Contains self-loops: {data.contains_self_loops()}')
print(f'Is undirected: {data.is_undirected()}')

輸出的結果為:

Dataset: Cora():
======================
Number of graphs: 1
Number of features: 1433
Number of classes: 7

Data(edge_index=[2, 10556], test_mask=[2708], train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])
======================
Number of nodes: 2708
Number of edges: 10556
Average node degree: 3.90
Number of training nodes: 140
Training node label rate: 0.05
Contains isolated nodes: False
Contains self-loops: False
Is undirected: True

Cora圖擁有2,708個節點,10,556條邊,平均節點度為3.9

訓練集僅使用了140個節點,占整體的5%。

我們還可以看到,這個圖是無向圖(undirected),不存在孤立的節點。

數據轉換(transform)在將數據輸入到神經網絡之前修改數據,這一功能可用於實現數據規范化或數據增強。在此例子中,我們使用NormalizeFeatures進行節點特征歸一化,使各節點特征總和為1。其他的數據轉換方法請參閱torch-geometric-transforms。

6.3、使用MLP神經網絡進行節點分類

多層感知機MLP神經網絡,其輸入層、隱藏層和輸出層,MLP神經網絡不同層之間是全連接的。

理論上,我們應該能夠僅根據文章的內容,即它的詞包特征表征來推斷文章的類別,而無需考慮文章之間的任何關系信息。接下來,讓我們通過構建一個簡單的MLP神經網絡來驗證這一點。
MLP神經網絡只對輸入節點的表征做變換,它在所有節點之間共享權重。

import torch
from torch.nn import Linear
import torch.nn.functional as F

class MLP(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(MLP, self).__init__()
        torch.manual_seed(12345)
        self.lin1 = Linear(dataset.num_features, hidden_channels)
        self.lin2 = Linear(hidden_channels, dataset.num_classes)
      def forward(self, x):
        x = self.lin1(x)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.lin2(x)
        return x
model = MLP(hidden_channels=16)
print(model)

MLP由兩個線性層、一個ReLU非線性層和一個dropout操作組成。

第一個線性層將1433維的節點表征嵌入(embedding)到低維空間中(hidden_channels=16

第二個線性層將節點表征嵌入到類別空間中(num_classes=7)。

6.3.1、MLP神經網絡的訓練

常見的損失函數有平方損失函數——常用於回歸類問題

交叉熵損失——常用於分類問題

交叉熵衡量的是數據標簽的真實分布與分類模型預測的概率分布之間的差異程度。

我們利用交叉熵損失和Adam優化器來訓練這個簡單的MLP神經網絡。

model = MLP(hidden_channels=16)
criterion = torch.nn.CrossEntropyLoss()  # 定義損失標准
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)  # 定義Adam優化器

def train():
    model.train()
    optimizer.zero_grad()  # 清除梯度
    out = model(data.x)  # Perform a single forward pass.執行單次向前傳播
    loss = criterion(out[data.train_mask], data.y[data.train_mask])  #根據訓練節點計算損失
    # Compute the loss solely based on the training nodes.
    loss.backward()  # Derive gradients,獲取梯度
    optimizer.step()  # Update parameters based on gradients.根據梯度更新參數
    return loss
  
import pandas as pd
df = pd.DataFrame(columns = ["Loss"])
df.index.name = "Epoch"
for epoch in range(1, 201):
    loss =train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
    df.loc[epoch] = loss.item()
df.plot()

img

6.3.2、測試

# 模型測試
def test():
    model.eval()
    out = model(data.x)
    使用概率最大的作為類別標簽
    pred = out.argmax(dim=1)  # Use the class with highest probability.
    # 根據實際的標簽進行檢查
    test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
    # 得出預測正確的比例
    test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
    # 返回預測正確的比例
    return test_acc

# 輸出模型的准確性
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

# 可視化訓練過的MLP模型輸出的節點表征
model.eval()
out = model(data.x)
visualize(out, color = data.y)

Test Accuracy: 0.5900

為什么MLP沒有表現得更好呢
其中一個重要原因是,用於訓練此神經網絡的有標簽節點數量過少,此神經網絡被過擬合,它對未見過的節點泛化能力很差。

6.4、卷積圖神經網絡(GCN)

輸入為C個通道,輸出為F個通道,\(Y_1\)\(Y_2\) 為節點標簽

將上面例子中的torch.nn.Linear替換成torch_geometric.nn.GCNConv,我們就可以得到一個GCN圖神經網絡,如下方代碼所示:

from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(GCN, self).__init__()
        torch.manual_seed(12345)
        self.conv1 = GCNConv(dataset.num_features, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, dataset.num_classes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GCN(hidden_channels=16)
print(model)

6.4.1、可視化由未經訓練的GCN圖神經網絡生成的節點表征

model = GCN(hidden_channels=16)
model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)

image-20210709144549201

經過visualize函數的處理,7維特征的節點被映射到2維的平面上。我們會驚喜地看到“同類節點群聚”的現象。

6.4.2、GCN圖神經網絡的訓練

通過下方的代碼我們可實現GCN圖神經網絡的訓練:

model = GCN(hidden_channels=16)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

def train():
      model.train()
      optimizer.zero_grad()  # Clear gradients.
      out = model(data.x, data.edge_index)  # Perform a single forward pass.
      loss = criterion(out[data.train_mask], data.y[data.train_mask])  # Compute the loss solely based on the training nodes.
      loss.backward()  # Derive gradients.
      optimizer.step()  # Update parameters based on gradients.
      return loss

for epoch in range(1, 201):
    loss = train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')

6.4.3、GCN圖神經網絡的測試

在訓練過程結束后,我們檢測GCN圖神經網絡在測試集上的准確性:

def test():
      model.eval()
      out = model(data.x, data.edge_index)
      pred = out.argmax(dim=1)  # Use the class with highest probability.
      test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
      test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
      return test_acc

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

輸出的結果為:

Test Accuracy: 0.8140

與前面的僅獲得59%的測試准確率的MLP圖神經網絡相比,GCN圖神經網絡准確性要高得多。這表明節點的鄰接信息在取得更好的准確率方面起着關鍵作用。

6.4.4、視化由訓練后的GCN圖神經網絡生成的節點表征

最后我們可視化訓練后的GCN圖神經網絡生成的節點表征,我們會發現“同類節點群聚”的現象更加明顯了。這意味着在訓練后,GCN圖神經網絡生成的節點表征質量更高了。

model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)

image-20210709144616026

6.5、圖注意力神經網絡(GAT)

import torch
from torch.nn import Linear
import torch.nn.functional as F

from torch_geometric.nn import GATConv

class GAT(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(GAT, self).__init__()
        torch.manual_seed(12345)
        self.conv1 = GATConv(dataset.num_features, hidden_channels)
        self.conv2 = GATConv(hidden_channels, dataset.num_classes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x
model = GAT(hidden_channels=16)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

def train():
      model.train()
      optimizer.zero_grad()  # Clear gradients.
      out = model(data.x, data.edge_index)  # Perform a single forward pass.
      loss = criterion(out[data.train_mask], data.y[data.train_mask])  # Compute the loss solely based on the training nodes.
      loss.backward()  # Derive gradients.
      optimizer.step()  # Update parameters based on gradients.
      return loss

for epoch in range(1, 201):
    loss = train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
    
def test():
      model.eval()
      out = model(data.x, data.edge_index)
      pred = out.argmax(dim=1)  # Use the class with highest probability.
      test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
      test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
      return test_acc

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
Test Accuracy: 0.7380

七、GCN圖神經網絡與GAT圖神經網絡的區別

在於采取的歸一化方法不同:

  • 前者根據中心節點與鄰接節點的度計算歸一化系數,后者根據中心節點與鄰接節點的相似度計算歸一化系數。
  • 前者的歸一化方式依賴於圖的拓撲結構:不同的節點會有不同的度,同時不同節點的鄰接節點的度也不同,於是在一些應用中GCN圖神經網絡會表現出較差的泛化能力。
  • 后者的歸一化方式依賴於中心節點與鄰接節點的相似度,相似度是訓練得到的,因此不受圖的拓撲結構的影響,在不同的任務中都會有較好的泛化表現。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM