社交网络链路预测实战:Python图算法与VGAE工程化指南
简介本资源是一套面向高校本科生与研究生的社交网络链路预测实践项目适用于毕业设计、课程设计及科研入门场景聚焦图神经网络与传统相似性指标在关系预测中的建模与对比分析。压缩包含345个文件总大小33.94MB其中21个Python脚本构成核心算法实现89个pkl文件存储预训练模型与嵌入结果41个txt和21个pdf涵盖数据说明、实验报告与使用教程另有svg/png图表、edgelist/feat等结构化图数据文件便于复现实验与拓展研究。已有55人学习下载资源经严格测试可直接运行配套详细md文档说明环境配置Python 3.6.6TensorFlow 1.12等、算法原理、数据加载流程及评估指标计算逻辑并完整覆盖VGAE、Node2Vec、谱聚类及Adamic-Adar等基线方法为读者提供从理论理解、代码调试到结果可视化的闭环学习支撑。1. 社交网络链路预测不是“猜朋友”而是用 Python 把图结构里的隐藏关系显性化毕业设计能跑通、课程设计能答辩、项目开发能复用的完整闭环你手头有一张微博关注图或一个实验室合作网络或某高校学生微信好友关系快照——节点是人边是已存在的连接。现在你想回答一个看似简单却极难落地的问题“A 和 B 目前没互相关注但未来三个月内互关概率超过 75% 的可能性有多大”这不是靠直觉也不是靠“他俩都加了 C应该认识”这种模糊推理。链路预测的本质是把图的拓扑结构、节点属性、路径模式翻译成可量化、可训练、可验证的概率值。这个资源包不是教科书里的公式推导而是一套开箱即用、带数据、带文档、带环境锁、带对比实验脚本的 Python 工程化实现。它覆盖从传统启发式Adamic-Adar到图神经网络VGAE的全栈方法所有算法统一输入格式.allfeat文件、统一评估接口AUC/precisionk、统一可视化逻辑。特别适合三类人本科生做毕业设计有完整 README 和答辩 PPT 框架、研究生做课程设计含 baseline 对比表格和消融实验模板、工程师快速验证新业务场景如电商用户潜在协同购买、内部知识图谱补全。它不承诺“一键出结果”但承诺“每一步命令都有回溯依据每个参数改动都有影响说明”。2. 理解.allfeat文件格式与图数据加载机制为什么必须用107.allfeat而不是 CSV 或 GML2.1.allfeat是什么它不是自定义格式而是为链路预测任务量身定制的稀疏图特征封装.allfeat文件名中的数字如107.allfeat对应的是公开图数据集的 ID 编号源自 SNAP 数据集Stanford Network Analysis Project中经预处理后的标准切片。这类文件并非原始邻接矩阵而是包含节点特征向量 邻接关系 训练/验证/测试边划分的二进制打包体。其内部结构由numpy.savez_compressed生成解压后包含四个关键键adj: scipy.sparse.csr_matrix 类型的邻接矩阵N×N仅存储非零边内存占用比 dense 矩阵低 90% 以上features: numpy.ndarray (N×F)每个节点的 F 维初始特征如度中心性、聚类系数、PageRank 分数等train_edges: (E_train, 2) 形状的 int64 数组每行是(src, dst)表示训练用正样本边val_edges,test_edges: 同理但含正负样本对负样本通过随机采样生成保证与正样本数量 1:1。提示不要试图用pandas.read_csv打开.allfeat——它不是文本强行读会报UnicodeDecodeError也不要尝试networkx.read_gml()GML 不支持稀疏矩阵和特征向量嵌入。2.2 加载.allfeat的标准流程三步完成图构建与数据分割import numpy as np import scipy.sparse as sp from sklearn.model_selection import train_test_split def load_allfeat(filepath): 加载 .allfeat 文件并返回标准化图数据结构 data np.load(filepath, allow_pickleTrue) # 步骤1加载邻接矩阵并转为对称无向图链路预测默认假设无向 adj data[adj].item() # 注意SNAP 的 .allfeat 中 adj 是 dict需 .item() adj adj adj.T # 强制对称 adj sp.csr_matrix(adj) # 确保为 CSR 格式后续计算加速 # 步骤2提取节点特征若不存在则用 one-hot 初始化 features data[features] if features in data else sp.eye(adj.shape[0]) # 步骤3加载边划分并确保负样本已预生成关键避免现场采样引入偏差 train_edges data[train_edges] val_edges data[val_edges] test_edges data[test_edges] return adj, features, train_edges, val_edges, test_edges # 实际调用示例 adj, features, train_e, val_e, test_e load_allfeat(data/107.allfeat) print(f图规模{adj.shape[0]} 个节点{adj.nnz} 条边) print(f训练边数{len(train_e)}验证边数{len(val_e)}测试边数{len(test_e)})这段代码的关键点在于adj.item()是必须的——因为.allfeat中adj存储为scipy.sparse._matrix的 pickle 包装对象直接索引会报KeyErroradj adj.T不是可选操作原始 SNAP 数据集中部分图是有向的如引用网络但链路预测任务默认建模为无向关系必须对称化features若为空则用单位矩阵sp.eye(N)作为初始特征这是 VGAE 和 Node2Vec 的通用 fallback避免后续 embedding 层输入全零。2.3 为什么不用 NetworkX 原生图对象CSR 矩阵才是链路预测的性能命脉NetworkX 的Graph对象在小图1k 节点上调试方便但在真实社交网络如1684.allfeat对应的 Facebook 大学子图含 63K 节点上nx.to_numpy_array()会瞬间吃光 32GB 内存。而scipy.sparse.csr_matrix在以下场景具备不可替代性场景NetworkX GraphCSR Matrix差异说明计算共同邻居数JaccardO(N²) 时间 O(N²) 内存O(nnz) 时间 O(nnz) 内存CSR 的.dot()可直接做稀疏矩阵乘法adj.dot(adj)一秒钟得到共同邻居计数矩阵构建随机游走序列Node2Vec需递归遍历易栈溢出用adj.indices[adj.indptr[i]:adj.indptr[i1]]直接获取第 i 行非零列索引速度提升 50 倍图卷积层VGAE前向传播无法直接参与 PyTorch autogradtorch.sparse.mm()原生支持 CSR 格式输入GPU 加速无阻所以整个项目源码中没有一行import networkx as nx用于核心计算——NetworkX 仅出现在utils/visualize.py中用于画小图示意生产级链路预测流程全程基于scipy.sparse和torch.sparse。2.4.allfeat文件的来源与替换指南如何用自己的社交网络数据生成兼容格式你不可能永远用107.allfeat。当你要接入公司内部 IM 好友图或电商用户关注图时必须生成自己的.allfeat。标准流程如下已封装在scripts/generate_allfeat.py中准备原始边列表edges.csv格式为src_id,dst_id整数 ID从 0 开始连续编号运行python scripts/generate_allfeat.py --input edges.csv --output mygraph.allfeat --split_ratio 0.6,0.2,0.2脚本自动完成构建邻接矩阵CSR计算节点度、聚类系数、PageRank 作为初始特征按比例划分训练/验证/测试边正样本并为每条正样本生成 1 条负样本随机采样未连接节点对保存为np.savez_compressed(...)格式。注意ID 必须从 0 开始且连续。若你的原始 ID 是字符串如user_12345必须先映射为0,1,2,...否则adj[i,j]索引会越界。generate_allfeat.py内置id_mapping模块支持 CSV 中字符串 ID 自动转换。3. 四类链路预测算法的原理差异与 Python 实现要点从 Jaccard 到 VGAE不是堆模型而是选对武器3.1 启发式方法Adamic-Adar / Jaccard / Preferential Attachment快、准、无需训练但泛化性差这三类方法统称为“局部相似性指标”核心思想是两个节点越“相似”越可能产生新连接。它们的区别在于“相似性”的数学定义方法公式物理意义Python 实现关键点Jaccard Coefficient$\frac{Γ(u) ∩ Γ(v)}{Adamic-Adar$\sum_{z∈Γ(u)∩Γ(v)} \frac{1}{\logΓ(z)}$Preferential Attachment$Γ(u)×def jaccard_score(adj, node_pairs): 批量计算 Jaccard 系数输入 node_pairs 为 (N,2) numpy array deg np.array(adj.sum(axis1)).flatten() # 每个节点的度 scores [] for u, v in node_pairs: # 获取 u 和 v 的邻居集合稀疏矩阵高效写法 neighbors_u adj[u].indices neighbors_v adj[v].indices # 计算交集与并集大小 intersection len(np.intersect1d(neighbors_u, neighbors_v)) union len(neighbors_u) len(neighbors_v) - intersection scores.append(intersection / union if union 0 else 0.0) return np.array(scores) # 示例对测试边计算 Jaccard 得分 test_scores jaccard_score(adj, test_edges)这段代码的玄学点在于np.intersect1d对小数组快但对大图如3437.allfeat的 2.2M 边会慢。真实项目中我们改用scipy.sparse的布尔运算def jaccard_fast(adj, node_pairs): 基于 CSR 矩阵的向量化 Jaccard速度提升 200 倍 # 将 node_pairs 转为稀疏矩阵索引 rows node_pairs[:, 0] cols node_pairs[:, 1] # adj[u] * adj[v].T 得到共同邻居数利用稀疏矩阵点乘 intersection np.array([ (adj[u] adj[v].T).sum() for u, v in node_pairs ]) # 并集 deg[u] deg[v] - intersection deg np.array(adj.sum(axis1)).flatten() union deg[rows] deg[cols] - intersection return intersection / np.where(union 0, union, 1e-8) # 防除零这就是为什么项目里baselines/jaccard.py有两个版本jaccard_slow.py供教学理解jaccard_fast.py供实际运行。3.2 Node2Vec用随机游走 Word2Vec 学习节点嵌入本质是“图上的词向量”Node2Vec 不是端到端模型而是一个两阶段 pipelineStage 1在图上执行 biased random walk带返回参数 p 和进出参数 q生成节点序列类似句子Stage 2将序列喂给 Word2Vecgensim.models.Word2Vec学习每个节点的 d 维向量。关键参数解释p1, q1→ DeepWalk无偏游走p1, q1→ 更倾向 BFS捕获结构性角色如“桥接节点”p1, q1→ 更倾向 DFS捕获同质性社区如“兴趣小组”。from gensim.models import Word2Vec def train_node2vec(adj, dimensions128, walk_length80, num_walks10, p1, q1): # Step 1: 生成随机游走序列已封装在 utils/walk.py walks generate_walks(adj, walk_length, num_walks, p, q) # Step 2: 训练 Word2Vec 模型 model Word2Vec( walks, vector_sizedimensions, window10, min_count0, sg1, # skip-gram workers4, epochs1 ) # Step 3: 提取节点嵌入注意Node ID 必须是 str 类型 embeddings np.zeros((adj.shape[0], dimensions)) for i in range(adj.shape[0]): if str(i) in model.wv: embeddings[i] model.wv[str(i)] else: embeddings[i] np.random.normal(0, 0.1, dimensions) # 未出现节点随机初始化 return embeddings # 使用嵌入计算链路得分余弦相似度 embeddings train_node2vec(adj) def node2vec_score(embeddings, node_pairs): scores [] for u, v in node_pairs: sim np.dot(embeddings[u], embeddings[v]) / ( np.linalg.norm(embeddings[u]) * np.linalg.norm(embeddings[v]) 1e-8 ) scores.append(sim) return np.array(scores)血泪经验walk_length和num_walks不是越大越好。walk_length80在107.allfeat~1K 节点上足够但在1684.allfeat~63K 节点上会导致内存爆炸。我一般会先用walk_length40, num_walks5快速验证再逐步放大。3.3 Spectral Clustering用图拉普拉斯矩阵的特征向量做降维适合发现全局社区结构Spectral Clustering 的链路预测思路是如果两个节点在谱嵌入空间中距离近它们很可能属于同一社区从而更可能连接。其核心是求解广义特征值问题$$\mathcal{L} y λ D y$$其中 $\mathcal{L} D - A$ 是非归一化拉普拉斯矩阵$D$ 是度矩阵。实际代码中我们用scipy.sparse.linalg.eigsh求前 k 个最小特征值对应的向量from scipy.sparse.linalg import eigsh def spectral_embedding(adj, k128): 计算谱嵌入返回 (N, k) 矩阵 n adj.shape[0] # 构建度矩阵 D对角阵 deg np.array(adj.sum(axis1)).flatten() D sp.diags(deg) # 构建拉普拉斯矩阵 L D - A L D - adj # 求解 L y λ D y 的前 k 个最小特征向量注意eigsh 默认求最大需指定 sigma0 eigenvals, eigenvecs eigsh(L, kk, MD, sigma0, whichLM, tol1e-3) return eigenvecs # shape (N, k) # 链路得分 谱嵌入的欧氏距离倒数 spectral_emb spectral_embedding(adj) def spectral_score(embeddings, node_pairs): scores [] for u, v in node_pairs: dist np.linalg.norm(embeddings[u] - embeddings[v]) scores.append(1.0 / (dist 1e-8)) # 防除零 return np.array(scores)注意eigsh的MD参数表示广义特征值问题中的质量矩阵漏掉它会导致嵌入完全失效——这是 Spectral 方法最常翻车的点。3.4 Variational Graph Auto-EncodersVGAE端到端可训练的图自编码器当前 SOTA 的基石VGAE 是本项目的核心创新点它把链路预测建模为图重构任务给定节点特征 $X$ 和邻接矩阵 $A$学习一个编码器 $q(Z|X,A)$ 生成隐变量 $Z$再用解码器 $p(A|Z)$ 重构邻接矩阵。其损失函数为$$\mathcal{L} \mathbb{KL}(q(Z|X,A) | p(Z)) \mathbb{E}_{q(Z|X,A)}[\log p(A|Z)]$$PyTorch 实现的关键模块import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class Encoder(nn.Module): def __init__(self, input_dim, hidden_dim, latent_dim): super().__init__() self.conv1 GCNConv(input_dim, hidden_dim) self.conv2 GCNConv(hidden_dim, latent_dim * 2) # 输出 mu 和 logvar def forward(self, x, edge_index): h F.relu(self.conv1(x, edge_index)) h self.conv2(h, edge_index) mu, logvar h[:, :h.size(1)//2], h[:, h.size(1)//2:] return mu, logvar class VGAE(nn.Module): def __init__(self, input_dim, hidden_dim, latent_dim): super().__init__() self.encoder Encoder(input_dim, hidden_dim, latent_dim) self.latent_dim latent_dim def reparameterize(self, mu, logvar): std torch.exp(0.5 * logvar) eps torch.randn_like(std) return mu eps * std def decode(self, z, edge_index): # 解码器z_i^T z_j 得到边概率 src, dst edge_index scores (z[src] * z[dst]).sum(dim1) return torch.sigmoid(scores) def forward(self, x, edge_index): mu, logvar self.encoder(x, edge_index) z self.reparameterize(mu, logvar) return z, mu, logvar # 训练循环关键片段 model VGAE(features.shape[1], 32, 16) optimizer torch.optim.Adam(model.parameters(), lr0.01) for epoch in range(200): model.train() optimizer.zero_grad() z, mu, logvar model(features, edge_index) # edge_index 从 adj 转换而来 recon_loss F.binary_cross_entropy( model.decode(z, pos_edge_index), # 正样本边 torch.ones(pos_edge_index.size(1)), # label1 reductionmean ) F.binary_cross_entropy( model.decode(z, neg_edge_index), # 负样本边 torch.zeros(neg_edge_index.size(1)), # label0 reductionmean ) kl_loss -0.5 * torch.mean(1 logvar - mu.pow(2) - logvar.exp()) loss recon_loss kl_loss loss.backward() optimizer.step()这里必须强调edge_index是 COO 格式[2, E]张量需从 CSR 矩阵转换def sparse_to_edge_index(adj): 将 scipy.sparse.csr_matrix 转为 PyTorch Geometric 的 edge_index coo adj.tocoo() values coo.data indices np.vstack((coo.row, coo.col)) i torch.LongTensor(indices) return iVGAE 的坑比想象中多recon_loss必须同时计算正负样本否则模型会坍缩到全输出 0.5kl_loss的系数不能设为 1否则早期训练不稳定我固定用0.01 * kl_loss。4. 避坑环境配置、数据加载、模型训练的五大血泪教训附现象→原因→解决4.1 现象ModuleNotFoundError: No module named tensorflow明明pip install tensorflow1.12.0成功了原因TensorFlow 1.12.0 仅支持 Python ≤3.7而你用的是 Python 3.8或者 Windows 上安装了tensorflow-cpu但代码依赖tensorflow-gpu本项目未强制 GPU但vgae.py中tf.Session()默认调用 GPU 设备解决严格使用 Python 3.6.6推荐pyenv或 Anaconda 创建独立环境pip install tensorflow1.12.0后运行python -c import tensorflow as tf; print(tf.__version__)确认若需 CPU 模式在vgae.py开头添加import os os.environ[CUDA_VISIBLE_DEVICES] -1 # 强制 CPU4.2 现象ValueError: Expected 2D array, got 1D array instead出现在sklearn.metrics.roc_auc_score原因roc_auc_score(y_true, y_score)要求y_score是一维数组但某些算法如 VGAE输出的是(N,1)形状的 tensor直接.numpy()后仍是二维解决统一在评估前 squeezey_score y_score.squeeze() if y_score.ndim 2 else y_score auc roc_auc_score(y_true, y_score)4.3 现象Node2Vec 训练卡死在Word2Vec的build_vocab阶段内存飙升至 100%原因walks列表中存在超长序列如walk_length200时单条 walk 含 200 个节点gensim默认为每个 token 分配哈希桶导致内存爆炸解决降低walk_length107.allfeat用 401684.allfeat用 20显式限制min_count1过滤低频节点或改用sg0CBOW模式内存更友好。4.4 现象Spectral Clustering 的eigsh报错ArpackNoConvergence: No convergence...原因k目标特征向量数过大或tol收敛容差过小尤其在稀疏度高的图如3437.allfeat上解决k不要超过min(128, adj.shape[0]//10)tol放宽至1e-2添加重试机制for _ in range(3): try: eigenvals, eigenvecs eigsh(L, kk, MD, sigma0, whichLM, tol1e-2) break except Exception as e: k max(10, k//2) # 动态降维4.5 现象VGAE 训练 loss 为 NaN或mu/logvar张量中出现inf原因logvar在exp(logvar)时溢出logvar 88→exp(88)1.6e38→ inf或z的范数过大导致decode中z_i^T z_j超出sigmoid输入范围解决在Encoder输出前 cliplogvar torch.clamp(logvar, -10, 10) # 限制 logvar ∈ [-10,10]在decode前 normalizezz F.normalize(z, p2, dim1) # L2 归一化5. 评估指标与结果可视化如何证明你的模型真的比 Jaccard 好不是看 AUC而是看 PrecisionK5.1 为什么 AUC 不足以说服答辩老师PrecisionK 才是业务可解释的指标AUC 衡量的是模型在整个阈值范围内的排序能力但它不告诉你“如果我只推荐 Top 10 个潜在好友其中有多少是真的”而PrecisionK直接回答这个问题$$\text{PrecisionK} \frac{\text{Top-K 预测中真实存在的边数}}{K}$$例如Precision10 0.7意味着“系统推荐的前 10 个潜在连接中有 7 个在未来确实发生了”。这对产品落地至关重要。def precision_at_k(y_true, y_score, k10): 计算 PrecisionK # 获取预测得分最高的 K 个索引 top_k_indices np.argsort(y_score)[::-1][:k] # 统计其中真实标签为 1 的数量 true_positives y_true[top_k_indices].sum() return true_positives / k # 示例对测试集计算 y_true_test np.ones(len(test_edges)) # 测试边全是正样本 y_score_test vgae_predict_scores(model, test_edges) # 自定义预测函数 p10 precision_at_k(y_true_test, y_score_test, k10) print(fPrecision10: {p10:.4f})5.2 多算法对比表格用真实数据说话拒绝“我觉得 VGAE 更好”项目文档REPORT.md中的对比表格不是摆设而是可复现的实证。以下是107.allfeat上的标准结果运行python run_all.py --dataset 107得到方法AUCPrecision10Precision20训练时间秒内存峰值MBJaccard0.8210.6320.5810.1245Adamic-Adar0.8390.6540.5980.1848Node2Vec0.8670.6920.63512.41890Spectral0.8530.6710.6128.71240VGAE0.8920.7380.68442.62150注意Precision10和Precision20的差距揭示了模型的“头部效应”——VGAE 在 Top-10 更准说明它对高置信度预测更可靠而 Jaccard 在 Top-20 下降更快说明其排序鲁棒性弱。5.3 可视化技巧用热力图看模型“注意力”而不是只画 ROC 曲线ROC 曲线千篇一律真正体现模型洞察力的是预测得分热力图。我们取test_edges中得分最高的 100 条边将其(u,v)映射到节点坐标用spectral_embedding降维到 2D再用matplotlib.scatter绘制import matplotlib.pyplot as plt from sklearn.manifold import TSNE # 用 t-SNE 降维比 PCA 更适合可视化 tsne TSNE(n_components2, random_state42) node_pos tsne.fit_transform(spectral_emb) # 节点 2D 坐标 # 获取 Top-100 预测边 top100_idx np.argsort(y_score_test)[::-1][:100] top100_edges test_edges[top100_idx] plt.figure(figsize(10, 8)) # 绘制所有节点 plt.scatter(node_pos[:, 0], node_pos[:, 1], clightgray, s1, alpha0.6) # 绘制 Top-100 预测边用箭头连接 for u, v in top100_edges: plt.arrow( node_pos[u, 0], node_pos[u, 1], node_pos[v, 0] - node_pos[u, 0], node_pos[v, 1] - node_pos[u, 1], head_width0.05, length_includes_headTrue, fcred, ecred, alpha0.3, lw0.8 ) plt.title(Top-100 Predicted Links (VGAE) on 107.allfeat) plt.axis(off) plt.savefig(fig/vgae_top100.png, dpi300, bbox_inchestight)这张图的价值在于如果 Top-100 边集中在图的某几个簇内说明模型捕捉到了社区内连接倾向如果跨簇连接多则说明它发现了桥接节点。答辩时指着这张图说“VGAE 不仅预测得准还告诉我们为什么准——它识别出了跨社区的枢纽人物”远胜于念 AUC 数字。5.4 模型可解释性补刀用 Grad-CAM 思路定位 VGAE 的关键邻居虽然 VGAE 是黑匣子但我们能用梯度反传粗略定位影响预测的关键邻居。对边(u,v)计算∂loss/∂A[u,v]即邻接矩阵该位置的梯度def get_attention_map(model, features, edge_index, target_edge): 获取 VGAE 对 target_edge 的注意力热图 model.eval() features.requires_grad_(True) z, mu, logvar model(features, edge_index) pred_score model.decode(z, torch.tensor([target_edge]).t()) # 反传梯度到 features pred_score.backward() grad features.grad.abs().sum(dim1) # 每个节点的梯度绝对值和 # 返回 top-5 关键邻居按梯度排序 _, top_nodes torch.topk(grad, 5) return top_nodes.tolist() # 示例 key_neighbors get_attention_map(model, features, edge_index, [10, 25]) print(fEdge (10,25) prediction most influenced by nodes: {key_neighbors})这招不是严格可解释但足够在答辩中展示“模型不仅输出概率还能指出‘为什么认为 10 和 25 会连接’——因为它重点关注了节点 15、33、7 等共同邻居”。6. 从毕业设计到工业落地如何把这套链路预测框架改造成可部署的服务三个硬核技巧6.1 技巧一用 ONNX 导出 VGAE 模型摆脱 TensorFlow 1.x 环境枷锁TensorFlow 1.12.0 已停止维护线上服务绝不能绑定死版本。解决方案将训练好的 VGAE 编码器导出为 ONNX 格式用onnxruntime推理# 在训练完成后导出 encoder import torch.onnx dummy_x torch.randn(100, features.shape[1]) # 示例输入 dummy_edge_index torch.tensor([[0,1],[1,0]], dtypetorch.long) torch.onnx.export( model.encoder, (dummy_x, dummy_edge_index), vgae_encoder.onnx, input_names[x, edge_index], output_names[mu, logvar], dynamic_axes{x: {0: batch_size}}, opset_version11 ) # 服务端加载无需 TensorFlow import onnxruntime as ort ort_session ort.InferenceSession(vgae_encoder.onnx) outputs ort_session.run(None, { x: features.numpy().astype(np.float32), edge_index: edge_index.numpy().astype(np.int64) }) mu, logvar outputs[0], outputs[1]提示ONNX 不支持torch.distributions.Normal的rsample()所以导出时只导Encoderreparameterize用 NumPy 实现。6.2 技巧二构建增量更新管道让模型不因新边到来而重新训练真实社交网络每天新增数千边不可能每天 retrain VGAE。我们采用双层缓存策略Level 1用 Jaccard本文还有配套的精品资源点击获取