GATv2 图注意力网络实现与源码解析:从静态注意力缺陷到 Cora 节点分类实战

发布时间:2026/9/18 5:11:21

GATv2 图注意力网络实现与源码解析:从静态注意力缺陷到 Cora 节点分类实战 GATv2 图注意力网络实现与源码解析从静态注意力缺陷到 Cora 节点分类实战【免费下载链接】annotated_deep_learning_paper_implementations‍ 60 Implementations/tutorials of deep learning papers with side-by-side notes ; including transformers (original, xl, switch, feedback, vit, ...), optimizers (adam, adabelief, sophia, ...), gans(cyclegan, stylegan2, ...), reinforcement learning (ppo, dqn), capsnet, distillation, ... 项目地址: https://gitcode.com/gh_mirrors/an/annotated_deep_learning_paper_implementations本篇技术指南围绕 annotated_deep_learning_paper_implementations 仓库中的 GATv2 文档 展开系统讲解 Graph Attention Networks v2GATv2算子的设计动机、数学原理并结合仓库内的 PyTorch 核心实现 与 Cora 训练代码 逐行剖析其前向传播细节与实验配置。读完本文你将掌握 GATv2 相对标准 GAT 的关键改进点动态注意力机制能够独立理解、复用并扩展该算子并能在 Cora 引文网络上复现两层 GATv2 的节点分类训练。一、GATv2 是什么面向图数据的注意力算子GATv2 是论文How Attentive are Graph Attention Networks?arXiv:2105.14491提出的图注意力算子本仓库以 PyTorch 完整实现相关代码与注释同时承担实现 教学双重角色。GATv2 面向图数据graph data工作。所谓图由一组**节点nodes和连接节点的边edges**构成。以 Cora 数据集为例图中的节点是研究论文边则是论文之间的引用关系——论文 A 引用论文 B就在两篇论文对应的节点间建立一条边。图神经网络的任务就是利用节点自身的特征向量以及节点之间的连接结构学习每个节点的高质量表示。在仓库结构中图神经网络相关实现统一放在 labml_nn/graphs/ 目录下其中gat/存放标准 GATgatv2/存放 GATv2 的算子实现、训练代码与本文所依据的 readme 文档labml_nn/graphs/gatv2/init.pyGraphAttentionV2Layer单层算子实现labml_nn/graphs/gatv2/experiment.py在 Cora 数据集上训练两层 GATv2 的完整实验代码labml_nn/graphs/gat/init.py 与 labml_nn/graphs/gat/experiment.py标准 GAT 的算子与训练代码用作对比参照。二、GATv2 要解决的问题标准 GAT 的静态注意力缺陷GATv2 的核心贡献是修复标准 GAT 的静态注意力static attention问题。为了理解这一点先看标准 GAT 的注意力分数计算方式。2.1 标准 GAT 的注意力公式标准 GAT 计算从查询节点 $i$ 到键节点 $j$ 的注意力分数 $e_{ij}$ 时先对源节点和目标节点使用同一个线性变换 $\mathbf{W}$再将两者拼接后与注意力向量 $\mathbf{a}$ 做内积最后经过 LeakyReLU 激活$$ \begin{align} e_{ij} \text{LeakyReLU} \Big(\mathbf{a}^\top \Big[ \mathbf{W} \overrightarrow{h_i} \Vert \mathbf{W} \overrightarrow{h_j} \Big] \Big) \ \text{LeakyReLU} \Big(\mathbf{a}_1^\top \mathbf{W} \overrightarrow{h_i} \mathbf{a}_2^\top \mathbf{W} \overrightarrow{h_j} \Big) \end{align} $$展开后可以看出拼接等价于把注意力向量 $\mathbf{a}$ 拆成 $\mathbf{a}_1$ 与 $\mathbf{a}_2$ 两部分分别作用于 $\mathbf{W}\overrightarrow{h_i}$ 与 $\mathbf{W}\overrightarrow{h_j}$再求和。2.2 缺陷所在注意力排名与查询节点无关关键观察在于对任意查询节点 $i$各键节点的注意力排名即对 $e_{ij}$ 做 $argsort$ 得到的次序只取决于 $\mathbf{a}_2^\top \mathbf{W} \overrightarrow{h_j}$ 这一项。因为 $\mathbf{a}1^\top \mathbf{W} \overrightarrow{h_i}$ 只与查询节点 $i$ 有关它作为常数项加到所有 $e{ij}$ 上不会改变排序。也就是说无论查询节点是谁被关注节点键的相对顺序始终保持一致。这种注意力被称为静态注意力——模型对所有查询都一视同仁地按同一顺序关注邻居无法针对不同的查询节点差异化地重新排列关注对象。2.3 静态注意力的失败场景论文用一个合成的字典查找dictionary lookup数据集展示了 GAT 的失败这是一个全连接的二分图一侧是查询节点query nodes每个查询节点关联一个 key另一侧节点同时关联一个 key 和一个 value。任务是根据查询节点的 key预测其对应 value。由于注意力是静态的GAT 无法针对不同查询灵活调整关注目标在这类任务上表现不佳。三、GATv2 的动态注意力改变算子运算次序GATv2 的改进思路非常直观交换线性变换与激活函数的次序让查询节点参与非线性变换之后再打分。其注意力分数定义为$$ \begin{align} e_{ij} \mathbf{a}^\top \text{LeakyReLU} \Big( \mathbf{W} \Big[ \overrightarrow{h_i} \Vert \overrightarrow{h_j} \Big] \Big) \ \mathbf{a}^\top \text{LeakyReLU} \Big( \mathbf{W}_l \overrightarrow{h_i} \mathbf{W}_r \overrightarrow{h_j} \Big) \end{align} $$对比标准 GATGAT 是线性变换 → 拼接 → 内积 → 激活且源与目标共享同一个 $\mathbf{W}$GATv2 是分别线性变换 → 相加 → 激活 → 内积并使用两个不同的矩阵 $\mathbf{W}_l$ 与 $\mathbf{W}_r$。由于 LeakyReLU 是非线性的$\mathbf{a}^\top \text{LeakyReLU}(\mathbf{W}_l \overrightarrow{h_i} \mathbf{W}_r \overrightarrow{h_j})$ 无法再分解为与 $i$ 无关的项 与 $j$ 无关的项因此注意力排名真正同时依赖查询节点与键节点——每个节点都可以关注到任意其他节点注意力变为动态的dynamic attention。更直白的等价理解GATv2 相当于先对 $\mathbf{W}_l \overrightarrow{h_i} \mathbf{W}_r \overrightarrow{h_j}$ 施加一个非线性变换再用向量 $\mathbf{a}$ 度量其方向其表达能力显著强于 GAT 中拼接后线性打分的线性度量。四、GraphAttentionV2Layer 源码级解析仓库的核心算子实现位于 labml_nn/graphs/gatv2/init.py核心类为GraphAttentionV2Layer定义于 第 62 行。一个 GATv2 网络由多个这样的层堆叠而成每层接收节点嵌入集合 $\mathbf{h} { \overrightarrow{h_1}, \overrightarrow{h_2}, \dots, \overrightarrow{h_N} }$其中 $\overrightarrow{h_i} \in \mathbb{R}^F$作为输入输出变换后的嵌入集合 $\mathbf{h} { \overrightarrow{h_1}, \dots, \overrightarrow{h_N} }$其中 $\overrightarrow{h_i} \in \mathbb{R}^{F}$。4.1 构造参数与内部模块__init__第 75-119 行接收以下参数参数符号含义默认值in_features$F$每个节点的输入特征数必填out_features$F$每个节点的输出特征数必填n_heads$K$注意力头数必填is_concat—多头结果是拼接还是取平均Truedropout—Dropout 概率0.6leaky_relu_negative_slope—LeakyReLU 负半轴斜率0.2share_weights—源节点与目标节点是否共享线性层False初始化逻辑中先根据是否拼接多头来确定每个头内部的隐藏维度若is_concatTrue断言out_features % n_heads 0令self.n_hidden out_features // n_heads即 $F K \times F_{head}$若is_concatFalseself.n_hidden out_features各头输出维度等于总输出维度最后取平均。随后构建五个核心模块第 104-119 行self.linear_l nn.Linear(in_features, n_hidden * n_heads, biasFalse)源节点查询侧线性变换 $\mathbf{W}_l$self.linear_r目标节点键侧线性变换 $\mathbf{W}_r$。若share_weightsTrue则直接复用linear_lself.linear_r self.linear_l否则新建独立矩阵self.attn nn.Linear(n_hidden, 1, biasFalse)输出注意力分数 $e_{ij}$ 的权重向量 $\mathbf{a}$self.activation nn.LeakyReLU(negative_slopeleaky_relu_negative_slope)注意力打分前的非线性激活self.softmax nn.Softmax(dim1)对每个查询节点的邻居做归一化得到注意力系数 $\alpha_{ij}$self.dropout nn.Dropout(dropout)对注意力系数施加正则。注意一个细节差异标准 GAT 中attn层的输入维度是n_hidden * 2拼接两个节点变换后的向量而 GATv2 中是n_hidden先相加再打分这正是两个算子结构差异在代码层面的直接体现。4.2 前向传播的五步流水线forward第 121 行起接收两个张量h节点嵌入形状[n_nodes, in_features]adj_mat邻接矩阵形状[n_nodes, n_nodes, n_heads]或[n_nodes, n_nodes, 1]仓库实现中因各头共享同一邻接结构而使用单通道adj_mat[i][j]为True表示存在从节点i到节点j的边。第一步初始双线性变换并切分多头第 131-138 行对每个头 $k$ 计算 $\overrightarrow{{g_l}^k_i} \mathbf{W_l}^k \overrightarrow{h_i}$ 与 $\overrightarrow{{g_r}^k_i} \mathbf{W_r}^k \overrightarrow{h_i}$g_l self.linear_l(h).view(n_nodes, self.n_heads, self.n_hidden) g_r self.linear_r(h).view(n_nodes, self.n_heads, self.n_hidden)第二步构造所有节点对的组合第 168-191 行为了一次性计算所有 $(i, j)$ 对的注意力分数代码用repeat与repeat_interleave构造笛卡尔积g_l_repeat g_l.repeat(n_nodes, 1, 1)把每个节点的嵌入整体重复n_nodes次得到序列 ${\overrightarrow{{g_l}_1}, \dots, \overrightarrow{{g_l}_N}, \overrightarrow{{g_l}_1}, \dots, \overrightarrow{{g_l}_N}, \dots}$g_r_repeat_interleave g_r.repeat_interleave(n_nodes, dim0)把每个节点的嵌入逐份交错重复n_nodes次得到 ${\overrightarrow{{g_r}_1}, \overrightarrow{{g_r}_1}, \dots, \overrightarrow{{g_r}_N}, \dots}$两者相加再view(n_nodes, n_nodes, n_heads, n_hidden)后g_sum[i, j]正好等于 $\overrightarrow{{g_l}_i} \overrightarrow{{g_r}_j}$。第三步计算注意力分数 $e_{ij}$第 193-201 行$$ e_{ij} \mathbf{a}^\top \text{LeakyReLU} \Big( \overrightarrow{{g_l}_i} \overrightarrow{{g_r}_j} \Big) $$e self.attn(self.activation(g_sum)) # [n_nodes, n_nodes, n_heads, 1] e e.squeeze(-1) # [n_nodes, n_nodes, n_heads]这里正是 GATv2 与 GAT 的分水岭GAT 是先拼接、再线性、后激活e self.activation(self.attn(g_concat))见 GAT 实现GATv2 是先相加、再激活、后线性。第四步邻接掩码与 softmax 归一化第 203-223 行先通过断言校验邻接矩阵形状合法性然后e e.masked_fill(adj_mat 0, float(-inf)) a self.softmax(e) a self.dropout(a)将不存在的边对应的 $e_{ij}$ 置为 $-\infty$使 $\exp(e_{ij}) \approx 0$从而 softmax 只在邻居集合 $\mathcal{N}_i$ 上归一化$$ \alpha_{ij} \text{softmax}j(e{ij}) \frac{\exp(e_{ij})}{\sum_{j \in \mathcal{N}i} \exp(e{ij})} $$随后对注意力系数施加 Dropout 正则。第五步加权聚合与多头合并第 225-236 行用 einsum 完成对目标节点变换结果的加权求和attn_res torch.einsum(ijh,jhf-ihf, a, g_r)即 $\overrightarrow{h^k_i} \sum_{j \in \mathcal{N}i} \alpha^k{ij} \overrightarrow{{g_r}_{j,k}}$。最后按is_concat决定输出方式拼接多头$\overrightarrow{hi} \Bigg\Vert{k1}^{K} \overrightarrow{h^k_i}$形状变为n_nodes * n_heads * n_hidden取平均$\overrightarrow{hi} \frac{1}{K} \sum{k1}^{K} \overrightarrow{h^k_i}$。五、两层 GATv2 网络与训练配置5.1 模型结构experiment.py 中的GATv2类第 20-65 行构造了一个两层 GATv2self.layer1 GraphAttentionV2Layer(in_features, n_hidden, n_heads, is_concatTrue, dropoutdropout, share_weightsshare_weights) self.activation nn.ELU() self.output GraphAttentionV2Layer(n_hidden, n_classes, 1, is_concatFalse, dropoutdropout, share_weightsshare_weights) self.dropout nn.Dropout(dropout)第一层in_features → n_hiddenn_heads个头且结果拼接is_concatTrue中间激活用nn.ELU()并在输入层和激活之后各施加一次 Dropout输出层n_hidden → n_classes仅 1 个头且结果取平均is_concatFalse不接激活函数直接输出分类 logits。forward的输入约定与单层算子一致x形状为[n_nodes, in_features]adj_mat形状为[n_nodes, n_nodes, n_heads]或[n_nodes, n_nodes, 1]。5.2 配置继承与覆盖Configs类第 68-79 行直接继承标准 GAT 实验的配置类GATConfigs定义于 GAT 训练代码因为两者的实验框架几乎完全一致只需替换模型class Configs(GATConfigs): # 源节点与目标节点是否共享权重矩阵 share_weights: bool False # 将模型切换为 GATv2 model: GATv2 gat_v2_modelshare_weights默认为False源、目标各用各的 $\mathbf{W}_l$、$\mathbf{W}_r$这是论文推荐设置的默认行为若设为True则复用同一个矩阵对应论文中的共享权重变体。从父类继承的关键配置项均可在运行前覆盖包括配置项默认值说明training_samples500参与训练的节点数其余节点用于验证in_features由数据集计算每节点输入特征数Cora 为 1433 维词袋向量n_hidden64第一层隐藏特征数n_heads8注意力头数n_classes由数据集计算分类类别数Cora 为 7dropout0.6Dropout 概率include_edgesTrue是否使用引用边设为False可测试丢掉图结构后的精度损失epochs1_000训练迭代轮数loss_funcnn.CrossEntropyLoss()分类损失函数deviceDeviceConfigs()训练设备可通过配置切换optimizer—通过OptimizerConfigs可配置化的优化器n_classes与in_features由calculate装饰器根据数据集自动推导见 GAT 训练代码无需手动指定。5.3 实验入口与超参数main()第 90-108 行创建名为gatv2的实验并设置优化器与正则化超参数experiment.create(namegatv2) experiment.configs(conf, { optimizer.optimizer: Adam, optimizer.learning_rate: 5e-3, optimizer.weight_decay: 5e-4, dropout: 0.7, })即使用Adam 优化器学习率 5e-3权重衰减 5e-4并将 Dropout 覆盖为0.7比父类默认的 0.6 更强。模型通过gat_v2_model工厂函数创建并移动到指定设备第 82-87 行。六、Cora 数据集与训练循环6.1 Cora 数据加载Cora 数据集的加载逻辑定义在 GAT 训练代码 的CoraDataset类中GATv2 实验直接复用数据内容cora.content文件为每篇论文提供二值词袋特征向量与 7 个类别之一的标签cora.cites文件记录论文间的引用对特征预处理特征向量按行归一化features / features.sum(dim1, keepdimTrue)features.shape[1]即in_features类别映射类别名映射为唯一整数索引类别数即n_classes邻接矩阵初始化为单位阵每个节点自带自环随后对每条引用边(e1, e2)同时置adj_mat[e1][e2] True与adj_mat[e2][e1] True构建对称无向图自动下载首次运行自动下载并解压数据集到 labml 数据目录由_download方法完成。6.2 训练循环Configs.run()第 194-253 行实现完整的训练流程关键点全批量训练Cora 数据集较小因此不采样、直接对全图训练注释说明若改为采样训练还需同时采样跨越所选节点的边数据划分用torch.randperm随机打乱节点索引前training_samples500个节点作为训练集其余作为验证集单步流程optimizer.zero_grad()→ 前向得到全图 logits → 只对训练节点计算交叉熵损失 →loss.backward()→optimizer.step()验证评估切换model.eval()在torch.no_grad()下对验证节点计算损失与准确率并通过tracker记录loss.train、loss.valid、accuracy.train、accuracy.valid指标。七、运行训练仓库使用 labml 实验框架安装依赖后即可直接运行。依赖清单见 requirements.txt核心为torch1.10与labml0.4.147也可通过setup.py以pip install -e .方式安装本项目。训练两层 GATv2 于 Cora 数据集python -m labml_nn.graphs.gatv2.experiment运行后会自动下载 Cora 数据集创建名为gatv2的实验按上述配置训练 1000 轮并记录损失与准确率指标。若想对比标准 GAT 在同一数据集上的表现可运行python -m labml_nn.graphs.gat.experiment对应 GAT 训练代码。八、GAT 与 GATv2 算子实现要点对比对比维度标准 GATGATv2注意力公式$e_{ij} \text{LeakyReLU}(\mathbf{a}^\top[\mathbf{W}h_i \Vert \mathbf{W}h_j])$$e_{ij} \mathbf{a}^\top \text{LeakyReLU}(\mathbf{W}_l h_i \mathbf{W}_r h_j)$源/目标变换共享同一 $\mathbf{W}$默认两个矩阵 $\mathbf{W}_l$、$\mathbf{W}_r$可选共享注意力类型静态键节点排名与查询无关动态排名同时依赖查询与键拼接 vs 相加变换后拼接attn输入维2 * n_hidden变换后相加attn输入维n_hidden激活位置打分之后再激活打分之前激活关键差异实验默认 Dropout0.60.7从源码结构可以推断GATv2 的改动虽然只涉及算子内部的运算次序与权重拆分却从根本上改变了注意力的表达能力——这正是动态注意力得以成立的关键。两个实验共用同一套 Cora 数据加载、训练循环与配置体系唯一的差异在于算子与模型因此 GATv2 实验 通过继承 GAT 实验 并覆盖model配置的方式最小化地复用了全部基础设施是仓库中以配置切换模型的典型范例。总结GATv2 以一处精巧的公式重排解决了标准 GAT 的静态注意力缺陷将 LeakyReLU 从打分之后移到打分之前配合源/目标分离的线性变换使得注意力分数能够真正随查询节点变化。仓库中的 单层算子实现 以可读的教学式注释完整呈现了双线性变换 → 笛卡尔积组合 → 激活打分 → 邻接掩码 → softmax → einsum 聚合 → 多头合并的全流程而 实验代码 则展示了如何复用 GAT 的配置与数据管线在 Cora 引文网络上直接训练一个两层 GATv2 完成论文分类任务。无论你是要复现论文、对比 GAT 变体还是将其作为组件接入自己的图学习项目这份实现都可以作为直接可用的起点。【免费下载链接】annotated_deep_learning_paper_implementations‍ 60 Implementations/tutorials of deep learning papers with side-by-side notes ; including transformers (original, xl, switch, feedback, vit, ...), optimizers (adam, adabelief, sophia, ...), gans(cyclegan, stylegan2, ...), reinforcement learning (ppo, dqn), capsnet, distillation, ... 项目地址: https://gitcode.com/gh_mirrors/an/annotated_deep_learning_paper_implementations创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
延伸阅读

更多相关文章

2026/9/18 5:11:21

价值流图(VSM)实战:读懂数据、核验修订与定位瓶颈

简介:这是一份聚焦精益生产与流程改进的VSM价值流图析PPT课件,适合制造企业管理、工业工程、质量管理等相关岗位学习者入门。课件系统梳理了价值流图的核心概念,强调从宏观流程识别浪费、衔接信息流与物料流,并结合看板、拉动系统…

2026/9/18 5:11:21

oh-my-hermes 本地部署实战:打造私有化 AI 智能体工作台

很早之前我就在关注“智能体”这个概念,但真正让我觉得“这东西终于能拿去干活”的,是在我把oh-my-hermes本地部署起来之后。它是一个很典型的 AI 智能体工作台类项目,名字带着点致敬oh-my-zsh的玩梗味道,做的事情却非常务实&…

2026/9/18 6:21:24

Pirate Voice

Pirate Voice 【免费下载链接】agents Build and deploy AI Agents on Cloudflare 项目地址: https://gitcode.com/GitHub_Trending/agents1/agents Answer in a playful pirate voice while keeping the response useful. Style Use light nautical phrasing such a…

2026/9/18 6:21:24

GyroFlow Windows 启动失败?三档排查把程序修回来

GyroFlow Windows 启动失败?三档排查把程序修回来 【免费下载链接】gyroflow Video stabilization using gyroscope data 项目地址: https://gitcode.com/GitHub_Trending/gy/gyroflow GyroFlow 是一款基于陀螺仪数据做视频防抖的开源工具。本文只解决一个问…

2026/9/18 6:16:24

无显示器Linux远程桌面花屏根因与修复:以麒麟2403为例

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 12:52:37

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/18 0:01:09

Google Colab 实战:运行模型、数据加载与报错排查

1. 为什么我劝你先搞懂 Colab 的运行模型1.1 Colab 到底是什么,跟本地跑代码差在哪Google Colab 简单说就是一台跑在浏览器里的 Linux 虚拟机,你打开一个 Notebook,背后就连上了一台带 GPU 的远程机器。你在单元格里敲的每一行 Python&#x…

2026/9/18 0:01:09

C语言数据类型与表达式详解

1. C语言数据与数据类型概述在C语言编程中,数据是程序处理的核心对象。理解数据的分类和特性是掌握C语言的基础。C语言中的数据主要分为四大类:常量、变量、表达式和函数。这些数据类型构成了C语言程序的基本元素,每种类型都有其独特的特性和…

2026/9/18 0:01:09

SQL时间字段指定时间段查询:区间语义、索引与时区避坑

上周排查一个线上问题&#xff0c;用户反馈"昨天的订单一条都没查到"&#xff0c;但数据库里明明躺着两千多条。最后定位下来&#xff0c;不是数据丢了&#xff0c;也不是接口挂了&#xff0c;而是那个查询条件把时间段写成了> 2024-05-20 00:00:00 AND < 2024…

2026/9/16 22:55:57

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行&#xff0c;Type-C接口算是典型的“看着简单&#xff0c;做起来全坑”的东西。光引脚就24个&#xff0c;高低速信号、电源、控制线全部塞在一个小小的连接器里&#xff0c;如果PCB布局不做规划&#xff0c;打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/16 22:56:09

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/16 22:56:16

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码