资讯详情

Python Leiden算法实战:从Louvain踩坑到模块度调优的完整指南

📅 2026/10/26 19:04:11 | 华诺云谱 👁 阅读
Python Leiden算法实战:从Louvain踩坑到模块度调优的完整指南
社区发现这个方向我前后折腾了差不多两年从最早的 NetworkX 自带的那套 Louvain 实现到后来换 igraph再到固定用 leidenalg 这个包中间踩的坑能写满两页纸。标题里这个需求——用 Python 调 leidenalg 跑 Leiden 算法、算模块度——看起来是个很窄的技术点实际上它是整个图聚类工作流里最容易出问题的一环算法选错、分辨率参数没调、模块度算出来跟预期对不上、有向图无向图搞混、权重列没传进去任何一个环节出岔子结果都能差出十万八千里。这篇就把我这些年在这块攒下来的东西完整摊开讲一遍从 Leiden 到底比 Louvain 强在哪、leidenalg 的接口怎么用、模块度怎么算才靠谱到实际跑数据时的参数调优和排错全部按能直接抄作业的标准来写。不管你是刚接触社区发现、只会调 sklearn 的新手还是已经在做图分析、想把手里的 Louvain 换成 Leiden 的老手下面这些内容应该都能对上你的需求。1. 先搞清楚 Leiden 到底解决了什么问题1.1 从 Louvain 的三个硬伤说起要理解 Leiden 的价值得先知道它替代的那个算法——Louvain——有什么毛病。Louvain 是 2008 年提出来的思路很朴素先把每个节点当成一个独立社区然后不断把节点往邻居社区里挪只要挪过去模块度能涨就挪挪不动了就把每个社区收缩成一个超级节点在超级节点构成的图上重复上面的过程直到模块度不再变化。这套流程跑起来快、效果也不差所以火了十几年。但它有三个绕不过去的硬伤。第一个是社区内部可能不连通。Louvain 在局部移动阶段只保证模块度上升不保证同一个社区里的节点真的连在一起。我见过最离谱的一次一个号称三百多节点的社区拆开一看里面分成了七八个互不相连的小块纯粹是因为它们在收缩阶段被合并到了一起。第二个是分辨率极限问题模块度这个目标函数本身有个内在尺度当社区小到一定程度时模块度的增益会趋近于零导致小社区被强行合并进大社区明明结构上该分开的两拨节点被硬塞到一起。第三个是局部最优陷阱Louvain 的移动策略是贪心的一旦某个节点挪进某个社区之后再也挪不出来哪怕后面有更好的全局方案也回不去了。这三个问题在实际数据上表现得很明显。我拿一份大概两万节点的用户行为关联图跑过对比Louvain 出来的社区数量比 Leiden 少了将近四成而且最大的那个社区占了全图三成以上的节点明显是被过度合并了。换成 Leiden 之后社区数量上来了最大的社区占比降到一成出头模块度反而还高了一点。这就是 Leiden 存在的意义——它不是简单地把 Louvain 跑得更快而是从算法结构上修掉了上面那几个洞。1.2 Leiden 的三步走和关键改进Leiden 的流程可以拆成三步跟 Louvain 的两步比多了一步但就是这一步救了命。第一步是局部移动这一步跟 Louvain 基本一样把节点在社区间挪动直到模块度不再上升。区别在于 Leiden 用的是快速局部移动的变体队列式的处理方式一个节点挪动之后只把受影响的邻居重新入队而不是每轮全图扫一遍实际跑起来比 Louvain 的朴素实现快不少。第二步是社区细化这是 Leiden 的核心创新。它把第一步得到的每个社区单独拎出来在这个社区内部的子图上再跑一次局部移动但这次有个硬约束细化出来的子社区必须和原社区保持连通。这一步直接解决了 Louvain 社区内部不连通的问题。细化完之后原来那个可能内部散架的社区被拆成了若干个保证连通的子社区。第三步是图收缩把细化后的子社区收缩成超级节点构建新的聚合图然后回到第一步继续。注意这里收缩的粒度是细化后的子社区而不是第一步的原始社区这个细节很关键——它保证了收缩后的图里每个超级节点对应的都是一块连通区域后续再怎么合并都不会出现内部断裂。提示很多人以为 Leiden 只是 Louvain 的加速版其实两者在结果质量上的差异主要来自第二步的细化。如果你的数据里社区结构比较规整、连通性本来就好两者结果可能差不多但只要数据有噪声、有孤立簇Leiden 的优势就会非常明显。1.3 模块度这个指标到底在衡量什么既然标题里明确要算模块度那就得把这个指标讲透不然算出来的数你也不知道该怎么解读。模块度的本质是实际社区内部边数和随机情况下期望边数的差值再归一化。公式写出来是 Q (1/2m) Σ_ij [A_ij - k_i*k_j/(2m)] δ(c_i, c_j)其中 A 是邻接矩阵k 是节点度m 是总边数δ 是指示函数两个节点同社区时为 1。用人话说就是如果社区划分得好社区内部的边应该比随机连边的情况下多得多Q 就接近 1如果划分跟随机差不多Q 就接近 0。这里有个特别容易踩的坑模块度的绝对值没有统一的好坏标准。网上经常看到模块度大于 0.3 就算有社区结构这种说法实际上这个阈值高度依赖图的规模和稀疏程度。我跑过一份稠密的小图模块度 0.6 结果一塌糊涂也跑过一份超稀疏的大图模块度 0.25 反而是最合理的划分。所以模块度更适合用来横向对比不同算法或不同参数下的划分质量而不是拿一个固定阈值去卡。这一点在后面的参数调优部分还会展开讲。另外模块度对分辨率很敏感这就是前面说的分辨率极限问题。leidenalg 里提供了resolution_parameter这个参数来调节默认是 1.0调大倾向于切出更多更小的社区调小倾向于合并成更少更大的社区。这个参数怎么定后面实操部分会给具体的调法。2. leidenalg 的环境准备和接口摸底2.1 依赖安装与版本匹配的坑leidenalg 这个包不是纯 Python 实现的它底层依赖 igraph 的 C 库所以安装的时候有几个地方要留意。最省事的装法是直接 pippip install leidenalg igraph但这里有个版本匹配的雷。leidenalg 对 igraph 的版本有要求如果 igraph 太老或者太新import 的时候可能报undefined symbol之类的错。我遇到过最典型的一次是 igraph 装了个很新的版本leidenalg 还是旧版跑起来直接段错误。稳妥的做法是让 pip 自己解决依赖别手动指定 igraph 版本或者装完之后用下面这段代码验证一下import igraph as ig import leidenalg as la print(igraph:, ig.__version__) print(leidenalg:, la.__version__)如果两个都能正常 import 且打印出版本号基本就没问题了。要是报错先pip uninstall igraph leidenalg把两个都卸干净再重新pip install leidenalg让它把 igraph 作为依赖一起装上这样版本匹配的概率最高。注意如果你在 Windows 上装偶尔会遇到编译工具链的问题。这种情况优先用 conda 装conda install -c conda-forge leidenalgconda-forge 上的预编译包比较全能绕开本地编译。2.2 图对象的构建方式leidenalg 不直接吃 NetworkX 的图它要的是 igraph 的 Graph 对象。所以第一步是把你的数据转成 igraph 图。常见的有三种构建路径我按使用频率排一下。第一种是从边列表直接建这是最常用的。假设你有一个边的列表每条边是 (源节点, 目标节点, 权重) 这样的三元组import igraph as ig edges [(0, 1, 2.5), (1, 2, 1.0), (2, 3, 3.2), (0, 3, 0.8)] g ig.Graph(edges[(u, v) for u, v, w in edges], directedFalse) g.es[weight] [w for u, v, w in edges]注意这里directedFalse是有讲究的。社区发现绝大多数场景下用的是无向图因为模块度的定义本身是基于无向图的。如果你的原始数据是有向的比如关注关系、调用关系通常的做法是先转成无向再跑或者用 Leiden 的有向版本后面会提。直接拿有向图跑无向算法igraph 会给你警告结果也不一定对。第二种是从 NetworkX 转过来。如果你前面的数据处理是用 NetworkX 做的可以用igraph.Graph.from_networkx()直接转import networkx as nx import igraph as ig G nx.karate_club_graph() g ig.Graph.from_networkx(G)这里有个细节NetworkX 的节点 ID 可能是字符串或者任意 hashable 对象转成 igraph 之后节点会变成从 0 开始的整数索引原来的 ID 存在g.vs[_nx_name]里。后面如果要按原始 ID 输出社区划分记得从这里取。第三种是从邻接矩阵建适合数据本来就是矩阵形式的情况import numpy as np import igraph as ig adj np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) g ig.Graph.Adjacency((adj 0).tolist(), modeundirected) g.es[weight] adj[adj 0].tolist()2.3 权重列的处理要点权重这块是新手最容易翻车的地方。leidenalg 在算模块度的时候如果图上有weight这个边属性它会自动用上如果没有就当成所有边权重为 1。问题在于很多人建图的时候权重列名字不叫weight或者权重是存在别的结构里没挂到边上结果算法默默按无权图跑了出来的结果跟预期完全对不上还找不到原因。所以建完图之后养成一个习惯先确认权重挂上了没有。print(g.es.attributes()) # 看有没有 weight print(g.es[weight][:5]) # 抽查前几个权重值如果weight不在属性列表里就得手动挂上去。另外权重的类型也有讲究igraph 的边属性支持数值类型但如果你传的是 numpy 的 float32 之类偶尔会有精度或者类型转换的怪问题稳妥起见转成 Python 原生 floatg.es[weight] [float(w) for w in weight_list]还有一个隐藏的坑权重不能有负数。模块度的推导假设边权是非负的如果数据里有负权比如某些相似度计算会产出负值要么先做平移把最小值拉到 0 以上要么把负权边直接过滤掉。我见过有人拿相关系数当权重直接扔进去相关系数有正有负跑出来的社区划分完全没法解释。3. 跑 Leiden 并计算模块度的完整实操3.1 最小可运行示例先把一个能跑通的最小例子摆出来确保环境没问题再往上叠复杂度。用 igraph 自带的空手道俱乐部图这个图是社区发现领域的经典测试数据34 个节点结构清晰适合验证流程import igraph as ig import leidenalg as la # 建图 g ig.Graph.Famous(Zachary) # 跑 Leiden指定模块度作为优化目标 partition la.find_partition( g, la.ModularityVertexPartition, seed42 ) # 输出结果 print(社区数量:, len(partition)) print(模块度:, partition.modularity) print(每个社区的节点:, partition)跑出来大概是 4 个社区模块度在 0.41 到 0.42 之间具体数值跟 seed 有关因为 Leiden 内部有随机性。这个结果跟文献里空手道俱乐部的最优划分基本吻合说明流程是对的。这里la.ModularityVertexPartition是优化目标意思是以模块度最大化为目标做划分。leidenalg 提供了好几种 partition 类型后面会讲怎么选。seed参数固定随机种子保证结果可复现——这个在做对比实验的时候特别重要不固定种子的话每次跑出来的社区数量和模块度都会有波动没法比较。3.2 带权图和分辨率参数的调法实际数据基本都带权而且往往需要调分辨率。带权图的跑法跟上面一样只要权重挂对了就行。分辨率参数通过resolution_parameter传partition la.find_partition( g, la.ModularityVertexPartition, resolution_parameter1.0, seed42 )分辨率参数怎么定这是实操里最需要经验的部分。我的做法是扫一遍参数看模块度和社区数量的变化曲线找一个拐点。具体来说把 resolution 从 0.2 到 2.0 按 0.1 的步长扫一遍记录每个取值下的社区数量和模块度import numpy as np results [] for res in np.arange(0.2, 2.1, 0.1): p la.find_partition( g, la.ModularityVertexPartition, resolution_parameterres, seed42 ) results.append({ resolution: round(res, 1), n_communities: len(p), modularity: p.modularity, max_size: max(len(c) for c in p) })扫完之后看两件事一是社区数量随分辨率上升的曲线通常在某个区间会有一个明显的跳变那个位置往往对应数据的真实结构尺度二是最大社区占比如果某个分辨率下最大社区突然吞掉大半节点说明分辨率太低被过度合并了。我一般会选一个社区数量合理、最大社区占比不超过 20% 到 30% 的取值。提示分辨率参数和模块度是联动的。调大分辨率会切出更多小社区模块度通常会下降因为模块度本身偏好中等规模的社区但这不代表划分变差了。判断划分好坏不能只看模块度还要结合社区大小的分布和业务含义。3.3 不同 partition 类型的选择leidenalg 里除了ModularityVertexPartition还有好几种 partition 类型选错了结果会差很多。常用的几个我列一下Partition 类型优化目标适用场景ModularityVertexPartition模块度最大化通用场景最常用RBConfigurationVertexPartition带分辨率的模块度需要精细控制社区规模RBERVertexPartition带配置零模型的模块度度分布差异大的图CPMVertexPartition恒定 Potts 模型需要指定社区规模下限SignificanceVertexPartition统计显著性小图、需要严格统计检验ModularityVertexPartition和RBConfigurationVertexPartition在数学上其实是等价的后者是前者带分辨率参数的推广形式实际用的时候如果不需要调分辨率用前者就行需要调分辨率就用后者语义更清晰。CPMVertexPartition比较特殊它不基于模块度而是基于一个叫恒定 Potts 模型的指标好处是可以显式指定社区的最小规模适合那种我知道社区不该太小的场景。我个人的习惯是默认用RBConfigurationVertexPartition因为它既能调分辨率语义又清楚跑出来的结果跟ModularityVertexPartition在 resolution1 时完全一致。只有在需要严格统计检验的时候才换SignificanceVertexPartition但那个跑得慢大图上不实用。3.4 有向图的处理前面提过模块度是基于无向图定义的但实际数据里有向图不少。leidenalg 提供了有向版本的 partition 类型后缀带Directedpartition la.find_partition( g, la.ModularityVertexPartition, seed42 )前提是建图的时候directedTrue。有向模块度的定义跟无向不一样它考虑的是边的方向性算出来的数值不能跟无向模块度直接比。我的经验是如果业务上方向确实有意义比如信息流向、调用依赖用有向版本如果方向只是数据采集的副产物比如用户 A 和用户 B 有互动谁先谁后不重要先转无向再跑结果更稳。转无向有个细节如果 A 到 B 和 B 到 A 都有边转无向的时候这两条边会合并成一条权重是相加还是取最大取决于你的业务含义。igraph 的to_undirected()默认是合并模式可以用combine_edges参数控制g_undirected g.to_undirected(combine_edgessum) # 权重相加4. 模块度计算的细节与结果解读4.1 partition.modularity 和手动计算的对账partition.modularity这个属性直接给出模块度用起来很方便但我建议至少手动算一次对个账确保你理解这个数是怎么来的。手动算的公式前面给过用 igraph 实现一版def manual_modularity(g, membership): m g.ecount() if m 0: return 0.0 degrees g.degree() q 0.0 for edge in g.es: u, v edge.tuple if membership[u] membership[v]: w edge[weight] if weight in g.es.attributes() else 1.0 q w - degrees[u] * degrees[v] / (2 * m) return q / (2 * m)拿这个函数跟partition.modularity对比数值应该一致浮点误差范围内。对账的过程能帮你发现很多隐藏问题比如权重没挂上、图是有向的但按无向算了、自环没处理等等。我第一次对账的时候就发现自己的图里有自环模块度算出来偏高后来把自环去掉才正常。注意自环对模块度的影响很大。一个节点如果有一堆自环它的度会虚高导致模块度计算失真。建图之后建议先检查一下g.ecount()和sum(1 for e in g.es if e.source e.target)有自环的话根据业务决定是去掉还是保留。4.2 模块度的横向对比方法前面说过模块度绝对值不好直接判断好坏那怎么用呢我的做法是固定图、固定分辨率对比不同算法或不同种子的结果。比如同一张图分别跑 Louvain 和 Leiden比模块度和社区数量# Louvain louvain_p la.find_partition(g, la.ModularityVertexPartition, n_iterations2, seed42) # Leiden leiden_p la.find_partition(g, la.ModularityVertexPartition, seed42) print(fLouvain: {len(louvain_p)} 社区, 模块度 {louvain_p.modularity:.4f}) print(fLeiden: {len(leiden_p)} 社区, 模块度 {leiden_p.modularity:.4f})注意 leidenalg 里跑 Louvain 是通过n_iterations2实现的限制迭代次数这是个不太直观的接口设计。跑出来的对比结果通常是这样Leiden 的模块度略高或持平但社区数量更多、最大社区更小说明它切得更细更合理。还有一种对比是同一算法不同种子看结果的稳定性。跑 10 次不同种子记录模块度的均值和标准差标准差小的说明算法在这个图上比较稳定。我跑过一份数据Leiden 的模块度标准差只有 0.002Louvain 是 0.015差距很明显。4.3 社区结果的导出和后续使用跑完拿到 partition 之后通常要把结果导出来做后续分析。最直接的是拿到每个节点的社区标签membership partition.membership # 列表第 i 个元素是节点 i 的社区 ID如果要跟原始数据的节点 ID 对上得注意 igraph 的节点索引是从 0 开始的整数而你的原始 ID 可能是别的。前面提过从 NetworkX 转过来的话原始 ID 在g.vs[_nx_name]里import pandas as pd df pd.DataFrame({ node_id: g.vs[_nx_name] if _nx_name in g.vs.attributes() else range(g.vcount()), community: partition.membership }) df.to_csv(community_result.csv, indexFalse)导出之后建议做两件事一是统计每个社区的规模分布看看有没有异常大的社区可能是分辨率没调好二是抽样看几个社区内部的节点确认它们在业务上确实是一类东西。这一步是验证算法结果有没有实际意义的关键光看模块度数字是不够的。5. 实操中踩过的坑和排查手册5.1 常见报错与对应解法这块我按遇到频率排个序做成速查表遇到问题直接对号入座报错/现象可能原因解法undefined symboligraph 和 leidenalg 版本不匹配两个都卸载重装让 pip 自动解决依赖模块度算出来是 0 或负数权重没挂上或图是空的检查g.es.attributes()和g.ecount()社区数量只有 1 个分辨率太低或图太稠密调大resolution_parameter社区数量等于节点数分辨率太高或图太稀疏调小resolution_parameter结果每次跑都不一样没固定 seed加seed42参数内存爆掉图太大或用了 Significance partition换 Modularity partition或分块处理有向图跑出警告用了无向 partition 跑有向图换 Directed 版本或先转无向这里面最隐蔽的是模块度算出来是 0这个。表面看是算法没跑出结果实际上八成是权重没挂上导致所有边权重为 1而图又特别稀疏模块度自然接近 0。排查的时候先打印g.ecount()确认边数再打印g.es.attributes()确认权重列两个都对上了再看算法参数。5.2 大图上的性能优化leidenalg 本身性能不错但图大到几十万节点、上百万边的时候还是得注意几个点。第一是建图方式。用边列表建图比逐个add_edge快得多因为后者每次都要检查节点是否存在。如果数据量特别大可以考虑先用 numpy 把边整理成数组再一次性建图。第二是partition 类型的选择。SignificanceVertexPartition在大图上基本跑不动因为它要算统计显著性复杂度高。ModularityVertexPartition和RBConfigurationVertexPartition是线性复杂度大图上首选。第三是迭代次数。find_partition有个n_iterations参数默认是 -1跑到收敛。大图上如果只想要个大致结果可以设成 2 或 3能省不少时间代价是模块度可能略低。我实测过一份五十万边的图n_iterations-1跑了将近两分钟设成 2 只要二十秒模块度只低了 0.01 左右性价比很高。第四是并行。leidenalg 底层是单线程的多核用不上。如果图特别大可以考虑把图按连通分量拆开每个分量单独跑最后合并结果。连通分量之间本来就没有边拆开跑不影响模块度。5.3 结果不稳定的排查思路结果不稳定是社区发现里最让人头疼的问题表现是同样的图、同样的参数跑两次社区划分差很多。排查按这个顺序来先确认seed 固定了没有。Leiden 内部有随机初始化不固定 seed 每次结果都不一样这是正常的。固定 seed 之后如果还不稳定那就是别的问题。再确认图是不是有多个连通分量。如果图本身是散的每个连通分量内部的划分是独立的但分量之间的处理顺序可能受随机性影响。这种情况可以先提取最大连通分量单独跑或者对每个分量分别跑再合并。最后看分辨率是不是卡在临界值上。如果分辨率刚好在某个跳变点附近微小的随机扰动就可能导致社区合并或拆分。解决办法是把分辨率往两边各挪一点选一个结果稳定的取值。我遇到过一次特别诡异的不稳定排查了半天发现是数据里有一批权重极小的边1e-8 量级这些边在模块度计算里几乎不起作用但会影响局部移动阶段的决策顺序导致结果波动。后来把权重小于某个阈值的边过滤掉结果就稳定了。这个经验告诉我数据清洗比调参重要边权里的噪声会直接传导到结果上。5.4 模块度和业务含义的平衡最后说个偏经验的东西。模块度是纯结构指标它不知道你的业务含义。我见过模块度很高但业务上完全没法解释的划分也见过模块度一般但业务上特别合理的划分。所以实际项目里我的流程是先用模块度扫参数找到几个候选划分再拿业务知识去筛最后选那个模块度不差、业务能解释的。举个具体的例子。之前做一份内容标签的关联图模块度最高的划分把几个明显不同类的内容合到了一起原因是它们在结构上确实连得紧通过一批公共的桥接节点。但业务上这几个类不该合并后来调高了分辨率把它们拆开模块度降了 0.03但划分合理多了。这种取舍没法靠算法自动完成得靠人对数据的理解。提示如果业务上有明确的社区规模预期比如每个社区大概几十到几百个节点可以用CPMVertexPartition配合min_size参数直接约束社区规模下限比反复调分辨率省事。6. 一个完整的端到端脚本把上面的东西串起来给一个可以直接改改就用的完整脚本。这个脚本覆盖了从数据加载、建图、参数扫描、选优到结果导出的全流程import igraph as ig import leidenalg as la import pandas as pd import numpy as np def build_graph(edge_df, weight_colweight): 从 DataFrame 建图edge_df 需要 source, target 两列 edges list(zip(edge_df[source], edge_df[target])) g ig.Graph(edgesedges, directedFalse) if weight_col in edge_df.columns: g.es[weight] edge_df[weight_col].astype(float).tolist() return g def scan_resolution(g, res_range, seed42): 扫描分辨率参数返回结果表 records [] for res in res_range: p la.find_partition( g, la.RBConfigurationVertexPartition, resolution_parameterres, seedseed ) sizes [len(c) for c in p] records.append({ resolution: res, n_communities: len(p), modularity: p.modularity, max_community_ratio: max(sizes) / g.vcount(), median_size: np.median(sizes) }) return pd.DataFrame(records) def pick_best(df, max_ratio0.3): 按最大社区占比和模块度选一个折中方案 candidates df[df[max_community_ratio] max_ratio] if candidates.empty: candidates df return candidates.sort_values(modularity, ascendingFalse).iloc[0] # 主流程 edge_df pd.read_csv(edges.csv) # 你的边数据 g build_graph(edge_df) res_df scan_resolution(g, np.arange(0.2, 2.1, 0.1)) best pick_best(res_df) print(选中的分辨率:, best[resolution]) print(对应模块度:, best[modularity]) final_p la.find_partition( g, la.RBConfigurationVertexPartition, resolution_parameterbest[resolution], seed42 ) result_df pd.DataFrame({ node: range(g.vcount()), community: final_p.membership }) result_df.to_csv(final_communities.csv, indexFalse)这个脚本里pick_best的选优逻辑是我自己常用的先卡一个最大社区占比的上限默认 30%在满足条件的候选里选模块度最高的。这个逻辑不一定适合所有场景你可以根据业务调整比如改成社区数量在某个区间内或者中位社区规模在某个范围。跑这个脚本的时候有几个地方要按你的数据改edges.csv的列名、权重列名、分辨率扫描范围。如果图特别大把np.arange(0.2, 2.1, 0.1)的步长调大一点比如 0.2减少扫描次数。我个人在实际操作中的体会是Leiden 这套东西上手不难难的是把参数调到跟数据匹配。模块度是个好用的参照但别把它当成唯一标准多看看社区规模分布、多抽样看看社区内部的节点比盯着模块度小数点后几位有用得多。另外每次换数据或者换参数都固定 seed 跑一遍留个记录后面出问题了好回溯。这个习惯帮我省过好几次返工。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑