优化连接:图中的数学优化
原文:
towardsdatascience.com/optimizing-connections-mathematical-optimization-within-graphs-7364e082a984
断开的图。图片由作者用 Dall-E 2 创建。
图论及其应用简介
·发表在 Towards Data Science ·13 min read·2023 年 7 月 28 日
–
在这篇文章中,我们将深入探讨图中的数学优化,探索关键概念、算法和实际应用。图问题可以在许多地方找到。显而易见的例子有物流或社交网络分析,比如寻找一个配送公司的最佳路线或两个个体之间的最少连接数。但你知道图也适用于城市规划、疾病传播建模、欺诈检测、推荐引擎和网络安全吗?通过利用专为图设计的优化算法,数据科学家可以发现最佳解决方案,高效分配资源,并做出数据驱动的决策。
首先,我们将从介绍部分开始,以解释图的基础知识。然后,我们将深入探讨常见的图问题和算法,尝试解决这些问题。
图的基础知识
作为回顾,以下是图论的基础知识。
什么是图?
图由顶点(或节点)和边组成。如果顶点以某种方式相关,它们就通过边连接。要定义一个图,你需要所有顶点的名称,并且需要知道哪些顶点是连接的。
下面是一个具有顶点 {A, B, C, D, E} 和边 {{A, D}, {A, E}, {B, C}, {B, D}, {C, D}} 的图。
简单图。图片由作者提供。
有时,图中可能包含回路。回路是一条起点和终点相同的边(一个节点与自身连接)。
图论中还需了解的其他术语:
-
图的顺序等于其顶点数。
-
图的大小是边的数量(有时加上顶点数量)。
-
顶点的度数是它拥有的边的数量(一个回路在起点和终点处都算作两次)。
常见变体
之前的图示例也被称为简单图,因为它只包含顶点和(无向)边。但你可以通过提供额外的信息使它变得稍微复杂一些,通常更有趣。
下面是一个有向图(即 digraph)的示例,其中边指向特定方向:
有向图。图像由作者提供。
另一种适用于多种情况的图是加权图。在加权图中,边与一个权重相关联:这可以是任何东西,例如长度或成本。
加权图。图像由作者提供。
这里讨论的最后一种有趣的图形变体是多重图。在多重图中,同一条边可以出现多次:
多重图。图像由作者提供。
注意:有许多特殊类型的图形及其自身的属性。例如,一个每对顶点都用边连接的图称为完全图。
从问题到图
如何从问题创建图?让我们通过一些示例来探讨:
位置和距离
一个经典问题是有位置和位置之间距离的问题。在下面的邻接矩阵中,提供了位置之间的距离(如果距离大于 0,则有一条边)。从这个矩阵中,你可以轻松地创建一个图。
A B C D E F G H I
A 0 3 1 2 0 0 0 0 0
B 3 0 1 0 4 3 0 0 0
C 1 1 0 0 0 3 0 0 0
D 2 0 0 0 0 1 0 6 0
E 0 4 0 0 0 0 2 0 0
F 0 3 3 1 0 0 3 1 0
G 0 0 0 0 2 3 0 0 1
H 0 0 0 6 0 1 0 0 5
I 0 0 0 0 0 0 1 5 0
从矩阵创建的图。图像由作者提供。
你也可以从图中创建矩阵,常见的有关联矩阵和度数矩阵。
让我们用边的名称替换权重:
现在我们可以创建关联矩阵,它有一行对应每个节点,一列对应每条边。它显示了哪些节点通过边连接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
A 1 1 1 0 0 0 0 0 0 0 0 0 0 0
B 1 0 0 1 1 1 0 0 0 0 0 0 0 0
C 0 1 0 1 0 0 1 0 0 0 0 0 0 0
D 0 0 1 0 0 0 0 1 1 0 0 0 0 0
E 0 0 0 0 1 0 0 0 0 1 0 0 0 0
F 0 0 0 0 0 1 1 1 0 0 1 1 0 0
G 0 0 0 0 0 0 0 0 0 1 1 0 0 1
H 0 0 0 0 0 0 0 0 1 0 0 1 1 0
I 0 0 0 0 0 0 0 0 0 0 0 0 1 1
对于加权图,我们可以用相应边的权重替换一。
度矩阵是对角矩阵,计算附加到每个节点的边的数量。一个回路算作两次。对于之前的图,度矩阵将是:
A B C D E F G H I
A 3 0 0 0 0 0 0 0 0
B 0 4 0 0 0 0 0 0 0
C 0 0 3 0 0 0 0 0 0
D 0 0 0 3 0 0 0 0 0
E 0 0 0 0 2 0 0 0 0
F 0 0 0 0 0 5 0 0 0
G 0 0 0 0 0 0 3 0 0
H 0 0 0 0 0 0 0 3 0
I 0 0 0 0 0 0 0 0 2
注意:官方矩阵形式没有索引行和索引列。为了清晰起见,已添加。
流量
有向图常用于网络流动。例如在数据传输网络、交通运输或管道中。如果你知道两个节点之间的最大容量,你可以创建一个网络流图:
有源点(S)和汇点(T)的有向图。图像由作者提供。
源点(s)和汇点(t)是流的起点和终点。边是有向的,它们显示了节点之间流动的方向。
树图
一种特殊类型的图是树图。你可以在许多不同的地方找到它们:比赛日程、家谱、组织结构图和进化树。树是无向、无环和连通的。它不应包含循环或有向边,所有节点都与树相连。
三个树图。作者提供的图像。
树具有有趣的属性:
-
它总是有
n-1
条边(其中n
是节点的数量)。 -
树中的任意一对节点之间都有一条唯一的路径。
-
移除一条边将使图断开(将图分成两部分)。
社交网络
人与人之间的联系在图中可视化起来很美好。通过分析和可视化关系,你可以提取有价值的见解。这里是 7 个人的一个例子,如果他们在社交媒体上相互连接,他们就通过一条边连接:
人员网络。作者提供的图像。
通过查看此图表可能会出现的问题是:
-
谁是最重要的人物?
-
哪些人最相似?
-
我们可以预测缺失的链接吗?
-
我们如何以最佳方式对这些人进行聚类?
应用于图的常见算法
在将情况建模为图后,你可以解决手头的问题。对于上一段中的不同示例,我们将应用算法来寻找最短路径、计算最大流和最小割、确定最小生成树,并触及图分析中的话题。
最短路径
第一个问题是最短路径问题:我们应该如何从 A 到 B,同时保持路径尽可能短?
让我们考虑以下图表:
图。如何从 A 找到到 I 的最短路线?作者提供的图像。
一个常用于解决此问题的算法是 Dijkstra 算法。其工作方式如下:
步骤 1. 起始节点的距离为 0。将其余节点的距离设置为无穷大,这是它们的暂定距离。节点有三种类型:已访问、未访问和当前节点。在第一次运行中,当前节点是起始节点。
起始节点是黄色节点,距离为 0。所有其他距离设置为无穷大。作者提供的图像。
步骤 2. 查看当前节点的直接邻居,并计算到它们的距离(通过当前节点)。如果这个距离小于迄今为止的最小距离,则用新距离替换旧距离。为了发现最快的路线,如果距离被接受,则标记新路线(并在存在时删除之前标记的路线)。
查看当前节点的所有邻居,如果新距离更小,则用新距离替换暂定距离。将“接受的”路线(距离较短的路线)标记为绿色。作者提供的图像。
步骤 3。 现在可以将第一个节点标记为已访问(绿色),新的当前节点将是距离最短的未访问节点(下图中的黄色节点距离为 1)。对该节点继续执行步骤 2,并重复步骤 2 和 3,直到达到终点。
新的当前节点标记为黄色。以前的黄色节点标记为已访问。图片作者。
对第二个节点应用步骤 2。上面的节点得到新的距离 2,1+1(因为这比其暂定距离 3 更短)。我们可以在两个黄色节点中选择一个来再次应用步骤 2。图片作者。
如果我们重复这些步骤,最终解决后的图形如下所示:
使用 Dijkstra 算法寻找最短路径。图片作者。
现在我们找到了从起始节点到每个其他节点的最短距离,并附上了相应的路径(绿色边)!Dijkstra 算法被广泛应用并且受欢迎,因为它高效且避免了不必要的计算。
还有许多有趣的最短路径问题,例如中国邮差问题,在这个问题中,你需要在访问所有边的同时发现最短路径,或者旅行推销员问题,在这个问题中你需要访问所有节点。旅行推销员问题可以通过许多不同的方法来解决,例如使用混合整数规划(示例 2)或使用模拟退火(示例 1)。
最大流与最小割
在流图中,一个重要的问题是:从源点到汇点可以发送多少流量?边的权重对应于边的容量,这些容量永远不会被超过。每个节点的流入流量应该等于流出流量(源点和汇点除外)。
以下是一个最大流问题的示例:
带有源点(S)和汇点(T)的有向图。图片作者。
我们如何解决这个问题,并发现有多少流量可以到达汇点?对此有几种不同的算法。我们使用 Ford-Fulkerson 算法来解决这个问题。
步骤 1。 将所有边的初始容量设置为 0。
将所有边的初始容量设置为 0。图片作者。
步骤 2。 找到一个增广路径从源点到汇点。这是一条所有边的未使用容量大于 0 的路径,起点是源点,终点是汇点。你可以通过例如广度优先搜索或深度优先搜索来找到增广路径。
从源点到汇点的增广路径。图片作者。
步骤 3. 对于步骤 2 中的增广路径,我们可以找到瓶颈,即剩余容量最小的边。我们用这个值增加增广路径上所有边的容量。
容量增加了 3(瓶颈边的值)。图像由作者提供。
步骤 4. 继续执行步骤 2 和 3,直到没有增广路径。
另一个增广路径,更新后的容量为 5。5 是边缘中剩余容量最低的,因此容量更新为 5。图像由作者提供。
在继续这样做之后,最终不会再有增广路径。最终结果可能如下所示:
已解决!还存在其他可能的解决方案,可以得到相同的结果。图像由作者提供。
最大流量可以通过计算 11 + 1 + 4 = 16 很容易得到。这些是指向汇点的边的流量。这个问题也可以通过线性规划来解决。
与最大流相关的是最小割。最小割是找到将网络分成两个独立部分所需断开的连接的权重总和的最小值。它与最大流密切相关,因为当你找到了最大流的解决方案时,你也可以推导出最小割。它们是相等的。
最小割。将图分成两部分,同时尽量减少移除边的权重总和。这个图像中移除的边的权重就是最小割,这与最大流的值相同,在这个例子中为 16。图像由作者提供。
最小割用于图像分割,例如将对象与背景分离。最小割的另一个重要应用是分析复杂网络的可靠性,例如电网或通信网络,通过识别关键边缘,这些边缘的故障可能导致网络断开或性能下降。
最小生成树
想象一下你是一个铁路公司的老板,你想要找出如何用最少的铁路连接七个城市。在这种情况下,你要找的是最小生成树。如果我们可视化这个问题,它看起来像这样:
连接的城市。图像由作者提供。
我们如何计算这个图中的最小生成树?我们可以使用 Prim 算法,这是一种贪心算法,比较容易理解。
步骤 1. 随意选择图中的一个节点作为起点。这个节点是树的第一部分。
选择红色房子作为起点。
步骤 2. 通过一条边扩展树。选择与树连接的所有边中权重最小的边。
选择的边。来自中心城市的最小边。右上角的房子也在树中。图片来源于作者。
步骤 3. 重复步骤 2,直到所有节点都在树中。
接下来我们选择的边是权重为 3 的边:
树在生长中…… 图片来源于作者。
最终,我们找到了总长度为 17 的最小生成树。
最终的树用红色表示。图片来源于作者。
最小生成树有许多不同的应用。最直接的应用是网络设计,当一个网络必须以最低的权重完全连接时。这样的网络例子包括电信网络、计算机网络、电力分配系统和交通网络。你知道最小生成树也用于分类学、聚类分析、计算机视觉中的特征提取和手写识别吗?如果你深入研究最小生成树的应用,你会发现还有很多其他应用!
中心性与相似性
图分析利用图论中开发的工具和技术,以在不同领域(如社交网络、交通网络、计算机网络、生物学等)获得洞察力和做出明智的决策。
中心性和相似性是图分析中的两个核心概念。你可能听说过谷歌搜索使用的PageRank 算法。PageRank 是一种中心性算法,用于测量网络中节点的重要性或影响力,特别是在网页排名和超链接分析中。它根据每个节点的入链数量和质量分配一个数值评分,从而帮助识别网络中的关键节点。
介数中心性是另一种中心性测量方法。具有最高介数中心性的节点在所有其他节点之间充当最重要的桥梁。要找出一个节点的介数中心性,我们需要查看所有节点对,计算每个节点出现在两个其他节点之间的最短路径上的次数。出现在最多最短路径上的节点就是具有最高介数中心性的节点。
带有节点及每个节点的介数中心性的图。图片来源于作者。
在社交网络或现实世界系统中,具有高介数中心性的节点(例如,人员、地点或实体)在维持网络中不同部分之间的通信和连接方面发挥着至关重要的作用。它们充当重要的中介,确保高效的沟通并保持网络连接。
了解某些节点的相似性也很有用。这可以通过计算节点相似性来完成。Jaccard 相似度通常用于无权图。它通过计算两个节点共享的共同邻居数量与它们拥有的唯一邻居总数之比来衡量相似性。
让我们计算图中节点 2 和 4 的 Jaccard 相似度。它们共享 2 个邻居(节点 3 和 5),总的唯一邻居数量是 5(节点 1、2、3、4 和 5)。2 除以 5 得到的 Jaccard 相似度为 0.4。
节点相似性用于协同过滤技术,通过根据用户偏好的相似性向用户推荐项目。它还可以用于预测网络中的缺失或未来链接,或者用于检测簇和社区。
下一步是什么?
这只是图论和图分析世界的一瞥。在我们探索的内容之外,还有许多更多令人兴奋的问题和应用等待发现。图论的美在于其多样性;它提供了一种通过将现实世界挑战转化为可用巧妙算法解决的图形谜题的方式。
从社交网络到物流,许多现实世界的问题在图的领域中找到完美的匹配。揭示社区、发现隐藏的联系以及探索不同图类型的独特属性是图论中有趣的方面。
在数据科学领域,图成为盟友,使得创建可以对模型性能产生巨大影响的特征成为可能。图嵌入是解锁广泛数据集中新维度理解的关键。
希望这篇文章能激发你对图论奇妙世界的好奇心!
相关内容
结合两种力量的实际例子。
约束编程求解器的核心及其与混合整数编程的关系
解释、参数、优点、缺点和使用案例
使用 C 优化 LLM,并在您的笔记本电脑上运行 GPT、Llama 和 Whisper
在这篇文章中,我们将深入了解由 Georgi Gerganov 创建的出色张量库ggml
。它是如何工作的?张量创建过程是什么?我们可以从一些简单的例子开始吗?
·发表于 Towards Data Science ·15 分钟阅读·2023 年 9 月 23 日
–
图片来源:Aryo Yarahmadi 在 Unsplash
目录
-
实现一个简单的数学函数
1.1 上下文的定义
1.2 初始化张量
1.3 前向计算和计算图
1.4 编译和运行
-
对第一部分的最终备注
-
支持我的写作
大型语言模型(LLMs)正随处引起关注。报纸上充斥着大量的文字描述这个即将到来的新世界,保证“AI 终于来了”。尽管 LLMs 对我们的生活产生了切实的影响,但我们必须保持冷静,并对整个情况进行批判性分析。LLMs 的炒作让我想起了几年前“数据科学家”职位的炒作。2014 年,当我开始攻读博士学位时,我看到数据科学家职位的稳步增加,直到 2018 年左右达到顶峰。当时,新闻再次炒作,写道:“数据科学家:100 万美元的职业”或“21 世纪最性感的工作”——这些标题是否让你联想到 LLM 的标题?
一方面,LLM(大语言模型)是一项伟大的技术,是迈向更通用 AI 框架的一步。这些模型是深入 AI 的起点,我相信有一天大多数应用程序和技术将依赖于这些模型。然而,我常常在 Medium 上看到,对于这些模型,有时缺乏明确性。尽管这些模型的能力和惊人的成果无可置疑,但它们过于庞大,不易运行或训练。因此,公司在决定任何战略业务方向之前,需要对 LLM 有非常透彻的了解。最尖锐的问题之一是这些模型的巨大内存成本、大规模基础设施需求以及推理时所需的昂贵基础设施。
如果我们考虑基本的 LLM 结构,即转换器,我们可以识别出经典的编码器-解码器结构。在推理时,解码器需要有一个内存机制来确定给特定输入标记分配多少注意力分数。这个分数基于标记在句子中的可能位置以及它与剩余上下文的一致性。这种机制称为 KV 缓存。鉴于这个矩阵的大小,对于简单模型而言,2048 的上下文长度很容易就会占用 3TB 的内存。为了进一步加速计算,我们需要在 GPU 上运行。最后,整个解码器结构很难并行化。
鉴于此前言,我们是否可以找到一种折衷或权衡的解决方案,使我们能够在更简单的基础设施上运行这些计算?本文展示了 Georgi Gerganov 如何实现一个新的优化的基于 C 的张量库,称为ggml
。我在文中提到的提交是提交0f4e99b,这是 2022 年 9 月的提交,标志着ggml
冒险的开始。其逻辑是使用基础代码,以便让你对整个包有一个强有力的理解。
实现一个简单的数学函数
在跳到 LLM 之前(它将在第二篇文章中介绍),让我们尝试分解库的关键元素,以便计算一个非常简单的函数,如:f = ax²。
上下文的定义
ggml
中的一切都始于一个上下文。上下文定义了内存要求,以适应给定模型中的所有张量。上下文是从全局状态开始创建的:
// global state
struct ggml_state g_state;
struct ggml_state {
struct ggml_context_container contexts[GGML_MAX_CONTEXTS];
};
全局状态构建为一个context_container
,它是:
struct ggml_context_container {
bool used;
struct ggml_context context;
};
在容器中,我们可以注意到ggml
第一个版本的核心元素,即ggml_context
的存在:
struct ggml_context {
size_t mem_size;
void * mem_buffer;
bool mem_buffer_owned;
int n_objects;
struct ggml_object * objects_begin;
struct ggml_object * objects_end;
};
ggml_context
包含了有关我们可以使用多少内存以及内存缓冲区的所有信息,以便在我们不知道张量可能占用多少字节的情况下,我们可以拥有足够的内存。
然后,上下文用于初始化整个过程。ggml_init
启动初始化过程并返回:
*ctx = (struct ggml_context) {
.mem_size = params.mem_size,
.mem_buffer = params.mem_buffer ? params.mem_buffer : malloc(params.mem_size),
.mem_buffer_owned = params.mem_buffer ? false : true,
.n_objects = 0,
.objects_begin = NULL,
.objects_end = NULL,
};
*ctx
是一个新的上下文指针。我们可以使用GGML_PRINT
在源代码中调查*ctx
的输入对象,例如:
GGML_PRINT("%s: context %p with %zu bytes of memory\n", __func__, (void *) ctx, ctx->mem_size);
GGML_PRINT("%s: context %p with %d objects\n", __func__, (void *) ctx, ctx->n_objects);
GGML_PRINT("%s: context %p with %p object starting position\n", __func__, (void *) ctx, (void *) ctx->objects_begin);
GGML_PRINT("%s: context %p with %p object ending position\n", __func__, (void *) ctx, (void *) ctx->objects_end);
在我的 Apple MacBook M2 Pro 上,上下文已经初始化为 16 GB 内存、0 个对象,并且内存布局是objects_begin
和objects_end
的地址为 0x0。
objects_begin
和objects_end
确实是下一步,即在ggml_context
中创建张量的内存地址。
初始化张量
对于ggml
中的所有函数,总会找到一个协议实现,例如:
function_with_attribute
→ function_general
→ function_implementation
function_with_attribute
是具有特定任务的函数,例如ggml_new_tensor_1d
或ggml_new_tensor_2d
,分别生成 1D 或 2D 张量。这个特定函数调用function_general
,即实现的通用布局,例如ggml_new_tensor_Xd
将调用ggml_new_tensor
。最后,function_general
调用实现function_implementation
。这样,每次需要修改代码时,我们只需对实现进行操作,而不是修改所有特定函数。
要创建一个 1D 张量,我们可以使用ggml_new_tensor1d
。从实现协议中,我们可以看到ggml_new_tensor_1d
的代码如下:
struct ggml_tensor * ggml_new_tensor_impl(
struct ggml_context * ctx,
enum ggml_type type,
int n_dims,
const int* ne,
void* data) {
// always insert objects at the end of the context's memory pool
struct ggml_object * obj_cur = ctx->objects_end;
const size_t cur_offset = obj_cur == NULL ? 0 : obj_cur->offset;
const size_t cur_size = obj_cur == NULL ? 0 : obj_cur->size;
const size_t cur_end = cur_offset + cur_size;
size_t size_needed = 0;
if (data == NULL) {
size_needed += GGML_TYPE_SIZE[type];
GGML_PRINT("Size needed %zu ", size_needed);
for (int i = 0; i < n_dims; i++) {
size_needed *= ne[i];
}
// align to GGML_MEM_ALIGN
size_needed = ((size_needed + GGML_MEM_ALIGN - 1)/GGML_MEM_ALIGN)*GGML_MEM_ALIGN;
}
size_needed += sizeof(struct ggml_tensor);
if (cur_end + size_needed + GGML_OBJECT_SIZE > ctx->mem_size) {
GGML_PRINT("\n%s: not enough space in the context's memory pool\n", __func__);
assert(false);
return NULL;
}
char * const mem_buffer = ctx->mem_buffer;
struct ggml_object * const obj_new = (struct ggml_object *)(mem_buffer + cur_end);
*obj_new = (struct ggml_object) {
.offset = cur_end + GGML_OBJECT_SIZE,
.size = size_needed,
.next = NULL,
};
if (obj_cur != NULL) {
obj_cur->next = obj_new;
} else {
// this is the first object in this context
ctx->objects_begin = obj_new;
}
ctx->objects_end = obj_new;
struct ggml_tensor * const result = (struct ggml_tensor *)(mem_buffer + obj_new->offset);
ggml_assert_aligned(result);
*result = (struct ggml_tensor) {
/*.type =*/ type,
/*.n_dims =*/ n_dims,
/*.ne =*/ { 1, 1, 1, 1 },
/*.nb =*/ { 0, 0, 0, 0 },
/*.op =*/ GGML_OP_NONE,
/*.is_param =*/ false,
/*.grad =*/ NULL,
/*.src0 =*/ NULL,
/*.src1 =*/ NULL,
/*.n_tasks =*/ 0,
/*.perf_runs =*/ 0,
/*.perf_cycles =*/ 0,
/*.perf_time_us =*/ 0,
/*.data =*/ data == NULL ? (void *)(result + 1) : data,
/*.pad =*/ { 0 },
};
ggml_assert_aligned(result->data);
for (int i = 0; i < n_dims; i++) {
result->ne[i] = ne[i];
}
result->nb[0] = GGML_TYPE_SIZE[type];
for (int i = 1; i < GGML_MAX_DIMS; i++) {
result->nb[i] = result->nb[i - 1]*result->ne[i - 1];
}
ctx->n_objects++;
return result;
}
struct ggml_tensor * ggml_new_tensor(
struct ggml_context * ctx,
enum ggml_type type,
int n_dims,
const int* ne) {
return ggml_new_tensor_impl(ctx, type, n_dims, ne, NULL);
}
struct ggml_tensor * ggml_new_tensor_1d(
struct ggml_context * ctx,
enum ggml_type type,
int ne0) {
return ggml_new_tensor(ctx, type, 1, &ne0);
}
如你所见,我们有ggml_new_tensor_1d
调用ggml_new_tensor
,然后调用实现ggml_new_tensor_impl
。新张量的创建类似于列表的创建。正如 Georgi 所述,所有的新张量对象将放置在当前内存池的末尾,给定一个上下文,上下文的末尾将是对象指向的位置,其中ggml_object
定义为:
struct ggml_object {
size_t offset;
size_t size;
struct ggml_object * next;
char padding[8];
};
起初,所有张量都初始化为data == NULL
。核心是数据类型,在ggml
中可以是:sizeof(int8_t), sizeof(int16_t), sizeof(int32_t)
或sizeof(float)
。这些大小决定了在上下文中所需的内存量,因此每个张量都在内存段中得到了完美分配。
最后,创建了一个包含所有检索信息的对象:
*obj_new = (struct ggml_object) {
.offset = cur_end + GGML_OBJECT_SIZE,
.size = size_needed,
.next = NULL,
};
一旦计算出新张量的数据缓冲区struct ggml_tensor* const result = (struct ggml_tensor*)(memb_buffer + obj_new-> offset);
,就会返回分配的张量:
*result = (struct ggml_tensor) {
/*.type =*/ type,
/*.n_dims =*/ n_dims,
/*.ne =*/ { 1, 1, 1, 1 },
/*.nb =*/ { 0, 0, 0, 0 },
/*.op =*/ GGML_OP_NONE,
/*.is_param =*/ false,
/*.grad =*/ NULL,
/*.src0 =*/ NULL,
/*.src1 =*/ NULL,
/*.n_tasks =*/ 0,
/*.perf_runs =*/ 0,
/*.perf_cycles =*/ 0,
/*.perf_time_us =*/ 0,
/*.data =*/ data == NULL ? (void *)(result + 1) : data,
/*.pad =*/ { 0 },
};
让我们看一个简单的示例,操作 1D 张量,通过定义一个数学函数f = ax²:
#include "ggml/ggml.h"
#include "utils.h"
int main(int argc, char ** argv) {
// define the memory parameters e.g. 16GB memory
struct ggml_init_params params = {.mem_size=16*1024*1024,
.mem_buffer=NULL,
};
// create a computational context
struct ggml_context * ctx = ggml_init(params);
// define the input tensors
struct ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
// x is a variable parameters in our context
ggml_set_param(ctx, x);
// define a constant a
struct ggml_tensor *a = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
// return x²
struct ggml_tensor *x2 = ggml_mul(ctx, x, x);
// compute f = ax²
struct ggml_tensor *f = ggml_mul(ctx, a, x2);
return 0;
}
在定义输入张量之前,我们需要指定内存参数。在这种情况下,我们假设要使用 16 GB 的内存,并且这将成为上下文ggml_context * ctx
的一部分。然后,我们可以开始定义第一个张量x
,它将是参考变量(例如,我们想计算相对于x
的梯度)。为了让ggml
知道x
是我们的主要变量,我们可以将其作为参数添加到上下文中ggml_set_param(ctx, x);
目前我们没有执行任何计算。我们只是指示 ggml
关于我们的函数(或模型)及张量如何相互作用。需要理解的是,每个张量都有一个特定的 .op
操作。所有新张量初始化时都为 GGML_OP_NONE
。一旦我们对张量调用任何新操作,这一点会被修改。这会进入计算图,以便用户可以决定是否计算函数值或相对于输入变量的函数梯度(例如,在我们的案例中,我们可以要求计算相对于 x
的梯度)。
例如,ggml_mul
执行输入张量操作的变体。最初,tensor -> op
从 GGML_NONE
转换为 GGML_OP_MUL
:
struct ggml_tensor * ggml_mul_impl(
struct ggml_context * ctx,
struct ggml_tensor * a,
struct ggml_tensor * b,
bool inplace) {
assert(ggml_are_same_shape(a, b));
bool is_node = false;
if (!inplace && (a->grad || b->grad)) {
is_node = true;
}
if (inplace) {
assert(is_node == false);
}
struct ggml_tensor * result = inplace ? ggml_view_tensor(ctx, a) : ggml_dup_tensor(ctx, a);
// Here we are transforming the operation
result->op = GGML_OP_MUL;
result->grad = is_node ? ggml_dup_tensor(ctx, result) : NULL;
result->src0 = a;
result->src1 = b;
return result;
}
struct ggml_tensor * ggml_mul(
struct ggml_context * ctx,
struct ggml_tensor * a,
struct ggml_tensor * b) {
return ggml_mul_impl(ctx, a, b, false);
}
这些计算被封装在一个图计算结构中,该结构在推理时为我们处理的每个模型提供 forward
计算。
前向计算和计算图
目前我们只实现了函数 f = ax²。要执行实际操作,我们需要创建图计算。具体操作如下:
struct ggml_cgraph gf = ggml_build_forward(f);
// set initial params
ggml_set_f32(x, 2.0f);
ggml_set_f32(a, 3.0f);
// compute
ggml_graph_compute(ctx, &gf);
printf("k=%f\n", ggml_get_f32_1d(f,0));
ggml_build_forward
构建前向计算图。在前向步骤中,我们正在构建实际的计算图,这个图遍历所有节点并返回一个结构 ggml_cgraph
:
struct ggml_cgraph result = {
/*.n_nodes =*/ 0,
/*.n_leafs =*/ 0,
/*.n_threads =*/ 0,
/*.work_size =*/ 0,
/*.work =*/ NULL,
/*.nodes =*/ { NULL },
/*.grads =*/ { NULL },
/*.leafs =*/ { NULL },
/*.perf_runs =*/ 0,
/*.perf_cycles =*/ 0,
/*.perf_time_us =*/ 0,
};
对于上述示例,代码返回一个包含 3 个节点的图,分别是 x
、x²
和 a*x²
以及 1 个叶子。可以通过 ggml_graph_dump_dot
函数获得图的可视化表示:
// without defining `gf` above run this:
struct ggml_cgraph gf = ggml_build_forward(f);
ggml_graph_dump_dot(&gf, &gf, "name_of_your_graph");
其中 &gf
是对图结构的引用,“name_of_your_graph” 指代 ggml
生成的 dot
文件的名称。如果你想将其转换为图像,只需运行:
dot -Tpng name_of_your_graph -o name_of_your_graph.png && open name_of_your_graph.png
对于我们的示例,图形为:
图 1:函数 f=ax² 的计算图。第一个节点是输入参数 x,然后 x 乘以自身,最后与变量 a 相乘。变量 a 是图中的一个叶子,其值为 3.00
如后面所述,我们可以给变量赋值(例如在此情况下 a = 3.0
),我们可以看到图形具有以下内容:
-
一个初始的黄色节点,具有
GGML_OP_NONE
操作以定义x
-
一个
GGML_OP_MUL
操作,即x*x
-
一个粉色的叶子,指代另一个变量的值 (
a
) -
最终节点,绿色,另一个
GGML_OP_MUL
操作为a*x²
一旦所有张量被分配,我们将拥有一个最终的图,其中包含所有操作,从参数变量 x
开始。
计算图中的操作
ggml_compute_forward
是所有计算运行的地方。
此函数的输入参数是 struct ggml_compute_params * params, struct ggml_tensor * tensor
。params
指定了图中与张量相关联的操作。通过 switch...case
循环调用任何前向操作:
switch (tensor->op) {
case GGML_OP_DUP:
{
ggml_compute_forward_dup(params, tensor->src0, tensor);
} break;
case GGML_OP_ADD:
{
ggml_compute_forward_add(params, tensor->src0, tensor->src1, tensor);
} break;
case GGML_OP_SUB:
{
ggml_compute_forward_sub(params, tensor->src0, tensor->src1, tensor);
} break;
case GGML_OP_MUL:
{
ggml_compute_forward_mul(params, tensor->src0, tensor->src1, tensor);
} break;
...
...
每个操作都是根据张量的输入类型编码的:
void ggml_compute_forward_mul(
const struct ggml_compute_params * params,
const struct ggml_tensor * src0,
const struct ggml_tensor * src1,
struct ggml_tensor * dst) {
switch (src0->type) {
case GGML_TYPE_F32:
{
ggml_compute_forward_mul_f32(params, src0, src1, dst);
} break;
case GGML_TYPE_I8:
case GGML_TYPE_I16:
case GGML_TYPE_I32:
case GGML_TYPE_F16:
case GGML_TYPE_COUNT:
{
assert(false);
} break;
}
}
对于0f4e99b提交,仅实现了GGML_TYPE_F32
。这调用了主要的乘法实现。
void ggml_compute_forward_mul_f32(
const struct ggml_compute_params * params,
const struct ggml_tensor * src0,
const struct ggml_tensor * src1,
struct ggml_tensor * dst) {
assert(params->ith == 0);
assert(ggml_are_same_shape(src0, src1) && ggml_are_same_shape(src0, dst));
if (params->type == GGML_TASK_INIT || params->type == GGML_TASK_FINALIZE) {
return;
}
const int n = ggml_nrows(src0);
const int nc = src0->ne[0];
assert( dst->nb[0] == sizeof(float));
assert(src0->nb[0] == sizeof(float));
assert(src1->nb[0] == sizeof(float));
for (int i = 0; i < n; i++) {
float * x = (float *) ((char *) dst->data + i*( dst->nb[1]));
float * y = (float *) ((char *) src0->data + i*( dst->nb[1]));
float * z = (float *) ((char *) src1->data + i*( dst->nb[1]));
ggml_vec_mul_f32(nc,
(float *) ((char *) dst->data + i*( dst->nb[1])),
(float *) ((char *) src0->data + i*(src0->nb[1])),
(float *) ((char *) src1->data + i*(src1->nb[1])));
}
}
操作的核心在于for
循环。在这个循环中,我们处理结果张量x
、乘法项src0
和乘数src1
。特别是:
-
(char *) dst->data
将dst
的数据指针转换为char*
。这样做是因为指针算术应以字节为单位进行,而char*
是最灵活的类型。 -
i * (dst->nb[1])
计算当前行的字节偏移量。由于i
在每次迭代中递增,这实际上是根据步幅信息移动到内存中的下一行。 -
最后,使用
(float *)
将结果强制转换回float*
,以确保这些指针被解释为指向浮点值的指针。
在数值计算和张量操作的上下文中,步幅指的是沿张量的特定维度连续元素之间的步长,通常以字节为单位。理解和正确处理步幅对于高效的张量操作和内存管理至关重要。
操作ggml_vec_mul_f32
执行最终的乘法如下:
inline static void ggml_vec_mul_f32 (const int n, float * z, const float * x, const float * y) { for (int i = 0; i < n; ++i) z[i] = x[i]*y[i]; }
内联函数是 C 语言提供的一种机制(通过inline
关键字),用于建议编译器在调用点“原地”展开特定函数,而不是作为单独的函数调用。当你调用一个常规函数时,会有一些开销。这包括将参数压入栈中、设置新的栈帧以及执行返回操作。对于非常小且频繁使用的函数,这些开销可能相对昂贵。内联消除了这些开销,因为代码直接插入调用点。内联允许编译器执行在正常调用情况下不可能进行的优化。例如,当函数被内联时,编译器可以在调用者的上下文中查看其代码并进行相应优化。这可能包括常量折叠、死代码消除和其他优化,从而使代码运行得更快。
最终的简单代码
我们现在准备在ggml
中实现一个完整的代码,计算某些值的函数f = ax²。在examples
文件夹下,我们可以创建一个名为simple_example
的新文件夹。在那里,我们将有主文件main.cpp
:
#include "ggml/ggml.h"
#include "utils.h"
int main(int argc, char ** argv) {
struct ggml_init_params params = {.mem_size=16*1024*1024,
.mem_buffer=NULL,
};
// params set up
struct ggml_context * ctx = ggml_init(params);
// tensors
struct ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
// x as a parameter
ggml_set_param(ctx, x);
struct ggml_tensor *a = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1);
struct ggml_tensor *x2 = ggml_mul(ctx, x, x);
struct ggml_tensor *f = ggml_mul(ctx, a, x2);
// build the graph for the operations
struct ggml_cgraph gf = ggml_build_forward(f);
// set initial params
ggml_set_f32(x, 2.0f);
ggml_set_f32(a, 3.0f);
// compute
ggml_graph_compute(ctx, &gf);
printf("k=%f\n", ggml_get_f32_1d(f,0));
// print the graph
ggml_graph_print(&gf);
// save the graph
ggml_graph_dump_dot(&gf, &gf, "final_graph");
return 0;
}
在同一文件夹中,我们需要一个CMakeLists.txt
文件,以便我们可以使用ggml
库编译代码:
#
# simple_example
set(TEST_TARGET simple_example)
add_executable(${TEST_TARGET} main.cpp)
target_link_libraries(${TEST_TARGET} PRIVATE ggml ggml_utils)
最后,在文件examples/CMakeLists.txt
的末尾添加以下行:add_subdirectory(simple_example)
现在,一切都是相互关联的,并且可以正确编译和运行。
编译和运行
回到ggml
文件夹,按照README.md
文件中的说明,创建一个名为build
的文件夹并运行以下命令:
mkdir build
cd build
cmake ../
make simple_example
这将编译ggml
库,并生成一个simple_example
示例代码的二进制文件。我们只需输入./bin/simple_example
即可运行我们的代码。代码将执行计算,并打印出图形信息的形式,包括所有节点和叶子及其相关操作。对于每个操作,将给出计算时间的估算。记住,如果你想绘制最终图形,你需要运行dot -Tpng final_graph -o final_graph.png && open final_graph.png
第一部分的最终说明
在这篇第一篇文章中,我们开始深入了解ggml
的工作原理及其基本理念。特别是,我们深入探讨了:
-
ggml_context
以及在ggml
库中如何初始化和使用内存 -
如何初始化一个新的 1D 张量以及
ggml
中的协议实现 -
图形计算如何工作,检索图形计算并绘制出来
-
一个简单的示例,初始化一个数学函数并获取其计算图
在下一篇文章中,我们将处理 LLM,特别是 GPT。我们将看到如何在ggml
中实现和使用它们,最后,在我们的笔记本电脑上运行 GPT 模型。
支持我的写作
如果你喜欢我的文章,请通过以下链接支持我的写作,加入 Medium 的会员计划 😃
[## 通过我的推荐链接加入 Medium - Stefano Bosisio
作为 Medium 会员,你的一部分会员费用会用于你阅读的作者,同时你可以获得对每个故事的完全访问权限…
stefanobosisio1.medium.com](https://stefanobosisio1.medium.com/membership?source=post_page-----460c8bdd047e--------------------------------)
在 Apache Spark 中优化输出文件大小
关于管理分区、重新分区和合并操作的全面指南
·
关注 发表在 Towards Data Science ·6 min read·2023 年 8 月 11 日
–
想象一下你在掌管一个大型 Spark 数据处理操作。在 Spark 优化讨论中,一个经常提到的经验法则是,为了获得最佳的 I/O 性能和增强的并行性,每个数据文件的大小应接近 128Mb,这也是读取文件时的默认分区大小 [1]。
想象你的文件像在数据处理海洋中航行的船只。如果船只太小,它们会浪费很多时间停靠和重新起航,这比喻为执行引擎花费额外时间打开文件、列出目录、获取对象元数据、设置数据传输和读取文件。相反,如果你的船只太大且未使用港口的许多码头,它们不得不等待单一的长时间装卸过程,这比喻为查询处理等待直到一个读者完成读取整个文件,这会减少并行性[图 1]。
图 1 — 作者提供的图片
为了生动地说明文件大小优化的重要性,请参阅下图。在这个特定的例子中,每个表包含 8 GB 的数据。
然而,驾驭这种微妙的平衡并非易事,特别是在处理大型批处理作业时。你可能会觉得你失去了对输出文件数量的控制。本指南将帮助你重新获得控制权。
理解的关键:分区
写操作执行时,保存到磁盘的输出文件数量等于 Spark 执行器中的分区数量。 然而,在执行写操作之前评估分区数量可能很棘手。
在读取表时,Spark 默认读取最大大小为 128Mb 的块(尽管你可以使用sql.files.maxPartitionBytes
来更改这一点)。因此,分区的数量依赖于输入的大小。然而,实际上,分区的数量很可能等于sql.shuffle.partitions
参数。这个数字默认为 200,但对于较大的工作负载,这通常是不够的。查看这个视频以了解如何设置理想的洗牌分区数量。
如果在 ETL 过程中至少有一个宽变换,Spark 执行器中的分区数量等于sql.shuffle.partitions
。如果仅应用窄变换,则分区数量将与读取文件时创建的分区数量相匹配。
设置洗牌分区的数量仅在处理未分区的表时可以高层次地控制总分区数量。一旦进入分区表的领域,改变sql.shuffle.partitions
参数不会轻易地调整每个数据文件的大小。
转轮:重新分区与合并
我们有两种主要的方法来管理运行时的分区数量:repartition()
和coalesce()
。以下是简要说明:
**重新分区**
:repartition(partitionCols, n_partitions)
是一种延迟转换,具有两个参数——分区数和分区列。当执行时,Spark 会根据分区列在集群中重新排列分区。然而,一旦表被保存,关于重新分区的信息会丢失。 因此,在读取文件时,这个有用的信息将不会被使用。
df = df.repartition("column_name", n_partitions)
**合并**
:coalesce(num_partitions)
也是一种延迟转换,但只需一个参数——分区数。重要的是,合并操作不会在集群中洗牌数据——因此比**重新分区**
更快。 此外,合并只能减少分区数,如果试图增加分区数则无效。
df = df.coalesce(num_partitions)
主要的见解是,使用合并方法通常更有利。 这并不是说重新分区没有用;它确实有用,特别是当我们需要在运行时调整数据框的分区数时。
在我处理多个大小不一的表,并进行复杂转换和连接的 ETL 过程中,我发现 sql.shuffle.partitions
并不能提供我所需的精确控制。例如,在相同的 ETL 中,为两个小表和两个大表使用相同数量的洗牌分区会很低效——导致小表的分区过多或大表的分区不足。重新分区还有助于我避开倾斜连接和倾斜数据的问题 [2]。
话虽如此,重新分区在将表写入磁盘之前较不适用,并且在大多数情况下,可以用合并替代。在写入磁盘之前,合并比重新分区更具优势,原因有几个:
-
它防止了不必要的数据在集群中的重新洗牌。
-
它允许根据逻辑启发式排序数据。在写入之前使用重新分区方法时,数据在集群中重新洗牌,导致顺序丢失。另一方面,使用合并可以保留数据的顺序,因为数据是被收集在一起而不是重新分配。
让我们看看为什么数据排序至关重要。
未来的排序:数据排序的重要性
我们上面提到,当我们应用 repartition
方法时,Spark 不会将分区信息保存到表的元数据中。然而,在处理大数据时,这是一条至关重要的信息,原因有二:
-
它允许在查询时更快地扫描表。
-
它允许更好的压缩——如果处理的是可压缩格式(如 parquet、CSV、Json 等)。这篇文章 是理解原因的好资料。
关键要点是在保存之前排序数据。这些信息将保存在元数据中,并在查询时使用,从而使查询速度更快。
现在让我们探讨一下保存到非分区表和分区表之间的区别,以及为什么保存到分区表需要额外的调整。
管理分区表中的文件大小
对于非分区表,在保存操作过程中管理文件数量是一个直接的过程。无论数据是否排序,使用 coalesce
方法在保存之前都能完成任务。
# Example of using coalesce method before saving a non-partitioned table
df.coalesce(10).write.format("parquet").save("/path/to/output")
然而,当处理分区表时,这种方法并不有效,除非在合并之前数据已经被排序。为了理解为什么会这样,我们需要深入探讨在 Spark 执行器中数据排序与未排序时发生的操作 [fig.2]。
图 2 — 作者提供的图片
因此,保存数据到分区表的标准流程应该是:
# Example of using coalesce method after ordering the data in a partitioned table
df.orderBy("columnName").coalesce(10).write.format("parquet").save("/path/to/output_partitioned")
其他导航工具
除了 repartition
和 coalesce
,你可能会发现 maxnumberofrecords
很有用。这是一个防止文件过大的实用方法,并可以与上述方法一起使用。
df.write.option("maxRecordsPerFile", 50000).save("file_path")
最终思考
在 Spark 作业中掌握文件大小通常涉及试错。在存储空间便宜且处理能力触手可及的时代,容易忽视优化。但随着数据处理量达到 tera 和 petabytes,忘记这些简单的优化技巧可能会在金钱、时间和环境方面产生重大成本。
我希望这篇文章能帮助你有效调整 ETL 过程。愿你像经验丰富的海船船长一样,自信且清晰地驾驭 Spark 的海洋。
通过选择性知识图谱条件优化检索增强生成(RAG)
如何通过有针对性的增强来显著提高知识的相关性,同时保持语言流畅性
·发表于Towards Data Science ·7 分钟阅读·2023 年 12 月 28 日
–
使用人工智能软件来增强本文文本的语法、流畅性和可读性。
生成预训练模型在作为对话代理使用时表现出令人印象深刻的流畅性和连贯性。然而,它们面临的一个关键限制是缺乏对外部知识的支撑。如果仅依赖预训练的参数,这些模型往往会生成看似合理但实际上不正确的回答,这也被称为幻觉。
以往缓解这一问题的方法涉及将整个知识图谱与对话中提到的实体相关联,从而增强对话上下文。然而,这种对大型知识图谱的不加选择的条件带来了自身的问题:
天真的知识图谱增强的局限性:
-
很多 1-hop 上下文可能与对话无关,插入了不必要的噪音
-
编码整个知识子图会给序列长度限制带来压力
-
无法保证模型会使用相关事实进行生成
-
尽管有知识基础,幻觉的风险仍然存在
为了克服这一点,Kang 等人 2023 年提出了 SUbgraph Retrieval-augmented GEneration(SURGE)框架,具有三项关键创新:
知识一致的对话生成与上下文相关的子图检索、不变图编码和…
openreview.net](https://openreview.net/forum?id=WhWlYzUTJfP&source=post_page-----97a4cf96eb69--------------------------------)
-
上下文相关子图检索器:使用图神经网络检索器从图知识库中检索与对话上下文最相关的知识图事实。
-
高效图编码:根据关系扰动标记嵌入,同时仅编码子图实体而非所有三元组。保持排列和反演不变性。
-
图-文本对比学习:通过对比损失确保检索到的知识图与生成响应之间的一致性。
这使得能够为对话提供准确的事实背景,而不会因无关事实或模型限制而稀释。实验表明,SURGE 减少了幻觉现象,并改善了基础知识。
关键见解在于对个性化子图的选择性条件提供了聚焦的知识基础,而不会使预训练模型不堪重负。
由 Dall-E-3 生成
计划:
- 上下文相关知识检索;
- 不变知识编码;
- 强化知识一致性;
- 结果;
- 结论。
上下文相关知识检索:
-
使用上下文和三元组嵌入的相似性来建模检索分布
-
从图神经网络中获得的三元组嵌入用于捕捉关系结构
-
使得可以集中于最相关的事实,而不是所有知识图事实
SURGE 解决的关键挑战是仅从知识图中检索最相关的事实,而不是用所有上下文相关的实体淹没生成器。为了实现这种特定于上下文的选择,本文建议将检索建模为一个基于对话历史的知识图三元组分布。
从数学上讲,这个上下文条件的检索分布定义为:
pφ(z|x) ∝ exp(d(z)^T s(x))
其中:
-
x 是对话上下文
-
z 是一个知识图三元组
-
s(x) 为对话上下文生成稠密的嵌入
-
d(z) 为三元组生成稠密的嵌入
这里的关键见解是利用对话和三元组嵌入之间的相似性来建模相关性。
由于三元组包含作为图结构的实体和关系,普通语言模型编码器不够用。相反,图神经网络(GNNs)特别适合捕捉节点和边。GNNs 可以通过传播相邻嵌入来表示实体之间的关系依赖。
具体来说,节点嵌入是通过图卷积网络生成的:
e = GNN(e0; G)
而关系嵌入使用边超图网络:
r = GNN(r0; G∗)
其中 G* 表示对偶超图。
通过结合节点和边嵌入,完整的三元组嵌入可以嵌入语义关系和邻近性。这些三元组与编码器对话上下文向量的相似性提供了一个上下文相关的检索分布的基础。
不变知识编码:
-
高效地将检索到的子图编码到生成器转换器中
-
确保编码对关系的顺序和方向不变
-
唯一编码实体并根据关系扰动嵌入
在前一阶段检索到的上下文相关子图需要被编码到生成器变换器模型中,该模型将生成对话回应。然而,简单地编码符号三元组会遇到表示稳定性的问题。
具体来说,有两个期望的不变性属性:
-
置换不变性:三元组的顺序不应改变整体意义
-
关系反转不变性:正向和反向关系等效
当将知识图谱编码到用于对话的预训练语言模型中时,会出现几个实际问题:
-
长序列:将每个三元组事实编码为单词会导致极长的输入序列。这会给模型的上下文容量带来压力。
-
顺序依赖:打乱三元组的顺序会改变像 GPT-3 这样的模型看到的意义,因为它们非常依赖单词的顺序和位置。但三元组本质上是无序的——打乱事实不应改变整体意义。
-
方向差异:关系可以被反转而不改变核心意义(X 是 Y 的妻子 == Y 有丈夫 X)。但前置文本使这些看起来像完全不同的事实。
上述问题在编码结构化知识时对语言模型造成了不必要的压力。模型被大量标记所压倒,它们难以理解混乱或反向的三元组仍然传达相同的概念。
因此,理想情况下,我们需要一种方法以紧凑而稳定的方式编码知识。编码应该是:
-
高效的:不应导致成千上万的前置标记从而膨胀上下文空间。
-
顺序不变:打乱子图不应大幅改变意义。
-
方向不变:正向和反向关系应被等同对待。
SURGE 通过唯一编码实体,然后根据通过图神经网络检测到的关系明智地扰动它们的嵌入来解决这个问题。这为解码器提供了紧凑、稳定的形式。
引入了一种两步嵌入和扰动的方法:
独特的实体嵌入:
-
从三元组中提取独特的实体集 ENT(Z)
-
使用对话编码器嵌入这些实体
-
这种嵌入+排序提供了置换不变性
使用关系进行扰动:
-
对三元组使用图神经网络
-
GNN 提供关系感知的节点嵌入
-
应用变换β到实体嵌入:
β(f(a), Z) = (1 + γ) ∗ f(a) + δ
其中 γ, δ 是基于关系学习到的扰动因子。
这一步使用关系信息直接影响实体向量空间,同时保持高效的唯一实体基础编码。
优势:
-
向量空间编码符合生成器要求
-
不变性提供了稳定性和一致性
见解是通过集合和扰动生成不变性,而不是通过可变序列编码。
强制知识一致性:
-
知识图谱与生成回应之间的对比损失
-
将相关知识表示拉近到回应表示
-
改善回应在检索事实中的基础
即使在上下文相关的检索和高效编码之后,仍然无法保证生成器会实际利用提供的相关知识。幻觉的风险依然存在。
为了主动结合编码的子图,作者提议在图谱和回应表示之间添加一个跨模态对比损失:
Lcont = (1/2) * log (sim(ζ(z), ξ(h)) / ∑ξ(h’))
- (1/2) * log (sim(ζ(z), ξ(h)) / ∑ζ(z’))
其中:
-
z 是编码的知识子图
-
h 是解码器的隐藏状态
-
ζ 和 ξ 是投影嵌入
直观上,这种损失将编码的知识图谱拉近到其对应的回应表示,同时将其推离其他随机回应或知识图谱。
这使得模型能够主动区分相关知识-回应对与无关的对。这种区分压力促使模型将其回应基于编码的事实。
好处:
-
改善事实一致性
-
减少不支持的断言
-
允许追踪幻觉到检索错误
关键见解是,如果没有明确的对齐目标,两种模态的向量空间可能会分开,限制事实基础。对比损失作为一致性的归纳偏置。
端到端训练:
目标函数:整体训练目标是最大化生成正确回应的对数似然,求和于潜在知识子图:
L = Σp(Z|x) p(y|x,Z)
其中 p(Z|x) 是基于上下文的检索分布,p(y|x,Z) 是生成器分布。
训练过程:
-
使用编码器网络编码对话上下文 x
-
通过相似性搜索检索 top-k 子图 Z_i ~ p(Z|x)
-
使用 GNN + 扰动不变地编码 Z_i
-
通过解码器最大化每个样本的 p(y|x,Z_i)
-
另外,最小化 Z_i 和解码器状态之间的对比损失
因此,在对话批次中,通过共享参数优化检索分布和生成分布。
模型选择:
原则上,任何序列到序列的语言模型,如 T5、BART 或甚至 GPT-3,都可以作为生成模型,通过将编码的知识附加到输入上下文标记中。论文在实验中使用了 T5 模型,但可以进行替换。
好处:
-
统一的端到端训练将组件绑定在一起
-
边际似然汇总了整体视网膜性能
-
模块化架构允许模型扩展
结果:
-
在衡量知识相关性的指标中优于基线
-
定性示例显示了更多基于相关知识的事实回应
-
消融实验验证了每个组件的重要性
作者在 OpendialKG 和 KOMODIS 对话数据集上评估了 SURGE,这些数据集提供了配对的知识图谱。
定量改进:
-
SURGE 在知识相关性指标上超越了所有基准,如提出的 KQA(知识验证问答)指标,通过提取器测量事实正确性。
-
在评估语言流利度的现有自动指标如 BLEU、ROUGE 和 F1 上取得了新的最先进结果。
定性影响:
-
实例显示,SURGE 生成了更具信息性和基于相关知识的事实性回应,这些回应来自于选择性检索的子图。
-
基准方法通常遗漏关键事实,甚至在有完整上下文的情况下仍会幻觉出不相关的陈述。
消融研究:
- 去除对比学习等组件会显著降低知识一致性指标,显示出每个模块的必要性。
SURGE 通过针对性增强显著提高了知识相关性,同时保持了语言流利度。与知识无关和知识密集型基准相比的提升验证了选择性子图检索和基础的好处。
使用遗传算法在 Python 中优化电视节目调度
一个实践教程,讲解如何在 Python 中使用遗传算法优化电视节目调度
·
关注 发表在 Towards Data Science ·11 分钟阅读·2023 年 7 月 26 日
–
图片来源:Glenn Carstens-Peters 于 Unsplash
我已经很久没有在 Medium 上写新帖子了。在过去的两年里,我一直在研究通过机器学习和深度学习对传统媒体行业可以进行哪些改进。其中一个研究领域是优化技术。就像在每个行业一样,优化在媒体中也至关重要。因此,在这篇文章中,我想通过将电视节目规划整合到一种进化算法——遗传算法中来分享。请记住,这只是一个简单的实现。现实生活远比这种简单性复杂得多。
什么是优化?我们为什么需要优化?
什么是优化?我想从这个问题开始。优化是寻找使给定目标函数最小化或最大化的值。那目标函数是什么?目标函数是我们试图最大化或最小化的绩效度量的数学表示。如果问题是最小化问题,我们可以称之为成本函数;如果是最大化问题,我们可以称之为适应度函数。
让我们通过一个例子来丰富这个解释。停下来想象一下你拥有一家餐馆。我们的目标是通过修改菜单(菜单指的是菜单上的菜肴)来最大化其利润。首先想到的方法是使用更便宜的原料。通过降低所用原料的质量,你可以获得更多的利润。但这并不是现实中的运作方式。当你降低产品质量时,顾客会减少对低质量原料制作的菜肴的需求。因此,无法实现预期的目标。
正如你所理解的,餐馆老板创建一个菜单以最大化利润是一个优化问题。例如,餐馆老板可以分析哪些菜肴在什么时间销售,并制定一份路线图。优化技术将使餐馆老板能够做出基于数据的决策,并取得最佳结果。
现在想象你作为一个电视台的节目规划师。记住你的竞争对手很强,但你仍然有能够竞争的节目。主要需要决定的问题是哪些节目应该在什么时间播出。这看起来很简单。笔和一些纸就足够了。真的那么简单吗?
尽管电视节目规划看起来简单,但随着各种因素的参与,它变得非常复杂。以下是其中一些因素:
观众偏好: 观众偏好什么类型的电视内容?
时间段: 观众在什么时间段偏好什么样的节目?
引导节目和过渡节目: 一些节目会将它们在播出期间收集到的观众转移到下一个节目。
竞争频道的节目偏好: 竞争频道在什么时间播出什么节目?
假期、特殊场合和季节性趋势: 观众的偏好是如何变化的?是否存在任何现有趋势?
新内容与旧内容: 广播节目是新的吗?还是重播?
故事情节和悬念: 这个程序有故事情节吗?还是有悬念?
这些只是一些因素。你可能已经注意到,数十个因素会影响电视节目规划。因此,优化算法来解决这些问题是合适的。
什么是评价和遗传算法?
我将在这一部分简要介绍评价和遗传算法。进化算法(EAs)是一种优化技术,可以解决许多具有挑战性的优化问题,而无需特定的关于问题结构的知识;换句话说,它们是问题独立的。进化算法(EAs)可以处理线性和非线性目标函数,而无需关于问题结构的信息。
另一方面,遗传算法属于搜索算法家族,使用进化原理。通过实现繁殖和自然选择过程,它可以产生高质量的解决方案。遗传算法是解决优化问题的非常有效的技术。
你可以看到下面的简单遗传算法流程图。我们的第一步是创建初始种群。初始种群包含随机选择的染色体(更准确地说,初始种群是一组染色体)。创建种群后,为每个个体计算一个适应度函数值。遗传算法使用染色体来表示每个个体。每个个体的适应度值是独立的。在这种方式下,可以同时进行多次计算。适应度值计算完毕后,遗传算法的三个不同阶段就会开始发挥作用——选择、交叉和变异。选择阶段负责从种群中选择染色体。目标是创造更好的世代。交叉过程负责从选择的个体中产生新的后代。这个过程通常是通过一次选择两个个体,然后交换它们的染色体部分,来创建两个新的代表后代的染色体。最后,操作员在变异阶段改变一个或多个基因。这种变化的概率非常低。变异阶段最重要的特点是防止系统陷入局部最小值。
遗传算法流程图(Eser Saygın)
实施
我刚刚介绍了关于遗传算法的一些简单信息。现在我将使用 Python 逐步解释遗传算法。正如标题所示,我们的问题是哪个节目将在什么时间播出。首先,我应该强调一个重要的点。我们将要实现的问题是一个简单的示例。正如我提到的,许多因素影响了现实生活中问题的实现。因此,问题识别阶段是最耗时的部分。
步骤
首先,我们从定义数据集开始。正如我之前提到的,下面的集合是一个简单的示例。数据集显示了各种节目在 18 小时(06:00–24:00)内的收视率。在现实生活中,有必要在每个时间段内播出节目,以测量节目在不同时间段的收视率。
# Sample rating programs dataset for each time slot.
ratings = {
'news': [0.1, 0.1, 0.4, 0.3, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.5, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2],
'live_soccer': [0.0, 0.0, 0.0, 0.2, 0.1, 0.3, 0.2, 0.1, 0.4, 0.3, 0.4, 0.5, 0.4, 0.6, 0.4, 0.3, 0.4, 0.3],
'movie_a': [0.1, 0.1, 0.2, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.3, 0.5, 0.3, 0.4],
'movie_b': [0.2, 0.1, 0.1, 0.3, 0.2, 0.1, 0.2, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.5],
'reality_show': [0.3, 0.4, 0.3, 0.4, 0.4, 0.5, 0.3, 0.4, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.2, 0.2, 0.3],
'tv_series_a': [0.2, 0.3, 0.2, 0.1, 0.1, 0.2, 0.2, 0.4, 0.4, 0.3, 0.3, 0.3, 0.5, 0.6, 0.4, 0.5, 0.4, 0.3],
'tv_series_b': [0.1, 0.2, 0.3, 0.3, 0.2, 0.3, 0.3, 0.1, 0.4, 0.3, 0.4, 0.3, 0.5, 0.3, 0.4, 0.6, 0.4, 0.3],
'music_program': [0.3, 0.3, 0.3, 0.2, 0.2, 0.1, 0.2, 0.4, 0.3, 0.3, 0.3, 0.3, 0.2, 0.3, 0.2, 0.3, 0.5, 0.3],
'documentary': [0.3, 0.3, 0.4, 0.3, 0.2, 0.2, 0.3, 0.4, 0.4, 0.3, 0.2, 0.2, 0.2, 0.1, 0.1, 0.3, 0.3, 0.2],
'Boxing': [0.4, 0.3, 0.3, 0.2, 0.2, 0.1, 0.1, 0.1, 0.1, 0.3, 0.3, 0.3, 0.2, 0.3, 0.4, 0.3, 0.4, 0.6]
}
下面,你可以看到其他变量。这些变量是用于遗传算法的超参数。我还创建了两个不同的列表以供后续使用。
GEN = 100
POP = 50
CO_R = 0.8
MUT_R = 0.2
EL_S = 2
all_programs = list(ratings.keys()) # all programs
all_time_slots = list(range(6, 24)) # time slots
正如我们在文章中提到的,我们的首要任务是初始化种群。你可以在下面找到我为此目的创建的函数。正如你所见,该函数需要两个输入列表:一个节目列表和一个时间段列表。我们已经在上面定义了这些列表。该函数生成所有潜在的日程安排。
def initialize_pop(programs, time_slots):
if not programs:
return [[]]
all_schedules = []
for i in range(len(programs)):
for schedule in initialize_pop(programs[:i] + programs[i + 1:], time_slots):
all_schedules.append([programs[i]] + schedule)
return all_schedules
接下来,我们将定义我们的适应度函数。适应度函数负责测量每个日程安排的质量。它以日程安排为输入,并返回总的收视率分数。(我们称之为日程安排的列表是由电视节目组成的播出日程。)
def fitness_function(schedule):
total_rating = 0
for time_slot, program in enumerate(schedule):
total_rating += ratings[program][time_slot]
return total_rating
在定义了适应度函数后,我们可以进入选择阶段。选择阶段的目的是找到最优的日程安排。为此,我们可以使用我们创建的以下函数。该函数检查每个日程安排的适应度值,并选择具有最高值的那个。
def finding_best_schedule(all_schedules):
best_schedule = []
max_ratings = 0
for schedule in all_schedules:
total_ratings = fitness_function(schedule)
if total_ratings > max_ratings:
max_ratings = total_ratings
best_schedule = schedule
return best_schedule
选择阶段之后是交叉阶段。在交叉阶段,两个父代解在遗传算法的帮助下被结合形成新的后代。在电视节目安排问题中,这个过程改变了两个解决方案中找到的节目(基因)。这个过程创造了各种电视节目的组合。你可以在下面看到交叉函数。
def crossover(schedule1, schedule2):
crossover_point = random.randint(1, len(schedule1) - 2)
child1 = schedule1[:crossover_point] + schedule2[crossover_point:]
child2 = schedule2[:crossover_point] + schedule1[crossover_point:]
return child1, child2
最终阶段是突变阶段。正如我们之前提到的,在突变阶段,通过改变染色体的遗传物质来形成新的后代。在电视节目优化问题中,我们可以将其视为随机更改节目。记住,突变的概率非常低。此外,你还可以将这个可能性作为一个超参数进行分配。
def mutate(schedule):
mutation_point = random.randint(0, len(schedule) - 1)
new_program = random.choice(all_programs)
schedule[mutation_point] = new_program
return schedule
现在我们已经定义了所有函数,可以运行适应度函数了。
# calling the fitness func.
def evaluate_fitness(schedule):
return fitness_function(schedule)
我们需要的数据已经准备好。现在我们可以定义算法。这个算法将使用 initial_schedule、generations、population_size、crossover_rate、mutation_rate 和 elitism_size。我们之前已经描述过这些。由于它们是超参数,我们可以修改它们,但不需要。函数首先创建具有提供的初始计划的初始种群,然后添加随机计划。之后,它会对指定的代数运行一个循环,并为每一代生成一个新种群,使用选择、交叉和突变操作。精英策略有助于根据适应度评分保留上一代中最成功的个体。一旦种群更新,它就成为下一代的当前种群。之后,函数返回上一代的最佳计划。
def genetic_algorithm(initial_schedule, generations=GEN, population_size=POP, crossover_rate=CO_R, mutation_rate=MUT_R, elitism_size=EL_S):
population = [initial_schedule]
for _ in range(population_size - 1):
random_schedule = initial_schedule.copy()
random.shuffle(random_schedule)
population.append(random_schedule)
for generation in range(generations):
new_population = []
# Elitism
population.sort(key=lambda schedule: fitness_function(schedule), reverse=True)
new_population.extend(population[:elitism_size])
while len(new_population) < population_size:
parent1, parent2 = random.choices(population, k=2)
if random.random() < crossover_rate:
child1, child2 = crossover(parent1, parent2)
else:
child1, child2 = parent1.copy(), parent2.copy()
if random.random() < mutation_rate:
child1 = mutate(child1)
if random.random() < mutation_rate:
child2 = mutate(child2)
new_population.extend([child1, child2])
population = new_population
return population[0]
现在我们准备好获取结果。
initial_best_schedule = finding_best_schedule(all_possible_schedules)
rem_t_slots = len(all_time_slots) - len(initial_best_schedule)
genetic_schedule = genetic_algorithm(initial_best_schedule, generations=GEN, population_size=POP, elitism_size=EL_S)
final_schedule = initial_best_schedule + genetic_schedule[:rem_t_slots]
print("\nFinal Optimal Schedule:")
for time_slot, program in enumerate(final_schedule):
print(f"Time Slot {all_time_slots[time_slot]:02d}:00 - Program {program}")
print("Total Ratings:", fitness_function(final_schedule))
在遗传算法运行后,我们将初始最佳计划和遗传计划结合起来,创建最终的最优计划。最后,我们打印出分配程序的最优计划,显示时间段、相应的程序以及在最终最优计划中获得的总评分。
结论
节目规划对传统媒体行业的电视台至关重要,在竞争激烈的环境中尤其如此。在这种情况下,我们展示了如何利用遗传算法来改进电视节目排期,这是一个强大的工具,可以帮助最大化观众评分。考虑使用遗传算法来优化排程问题,比如电视节目排期。凭借其强大的能力,它可以帮助你创建一个最大化观众参与和评分的计划。
在我即将发布的文章中,我计划探讨各种遗传算法,如竞争性协同进化(CCQGA)和量子算法(QGA)。我可能还会在中间加入额外的内容。
感谢你抽出时间阅读这篇文章。如果你想与我联系,欢迎通过 LinkedIn 添加我。
www.linkedin.com/in/esersaygin/
来源
《动手实践遗传算法与 Python:应用遗传算法解决现实世界的深度学习和人工智能问题》 作者:埃亚尔·维尔桑斯基
《使用 Python 的工程师应用进化算法(第 1 版)》
作者:莱昂纳多·阿泽维多·斯卡尔杜亚
完整代码
import random
##################################### DEFINING PARAMETERS AND DATASET ################################################################
# Sample rating programs dataset for each time slot.
ratings = {
'news': [0.1, 0.1, 0.4, 0.3, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.5, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2],
'live_soccer': [0.0, 0.0, 0.0, 0.2, 0.1, 0.3, 0.2, 0.1, 0.4, 0.3, 0.4, 0.5, 0.4, 0.6, 0.4, 0.3, 0.4, 0.3],
'movie_a': [0.1, 0.1, 0.2, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.3, 0.5, 0.3, 0.4],
'movie_b': [0.2, 0.1, 0.1, 0.3, 0.2, 0.1, 0.2, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.5, 0.4, 0.3, 0.4, 0.5],
'reality_show': [0.3, 0.4, 0.3, 0.4, 0.4, 0.5, 0.3, 0.4, 0.5, 0.4, 0.3, 0.2, 0.1, 0.2, 0.3, 0.2, 0.2, 0.3],
'tv_series_a': [0.2, 0.3, 0.2, 0.1, 0.1, 0.2, 0.2, 0.4, 0.4, 0.3, 0.3, 0.3, 0.5, 0.6, 0.4, 0.5, 0.4, 0.3],
'tv_series_b': [0.1, 0.2, 0.3, 0.3, 0.2, 0.3, 0.3, 0.1, 0.4, 0.3, 0.4, 0.3, 0.5, 0.3, 0.4, 0.6, 0.4, 0.3],
'music_program': [0.3, 0.3, 0.3, 0.2, 0.2, 0.1, 0.2, 0.4, 0.3, 0.3, 0.3, 0.3, 0.2, 0.3, 0.2, 0.3, 0.5, 0.3],
'documentary': [0.3, 0.3, 0.4, 0.3, 0.2, 0.2, 0.3, 0.4, 0.4, 0.3, 0.2, 0.2, 0.2, 0.1, 0.1, 0.3, 0.3, 0.2],
'Boxing': [0.4, 0.3, 0.3, 0.2, 0.2, 0.1, 0.1, 0.1, 0.1, 0.3, 0.3, 0.3, 0.2, 0.3, 0.4, 0.3, 0.4, 0.6]
}
GEN = 100
POP = 50
CO_R = 0.8
MUT_R = 0.2
EL_S = 2
all_programs = list(ratings.keys()) # all programs
all_time_slots = list(range(6, 24)) # time slots
######################################### DEFINING FUNCTIONS ########################################################################
# defining fitness function
def fitness_function(schedule):
total_rating = 0
for time_slot, program in enumerate(schedule):
total_rating += ratings[program][time_slot]
return total_rating
# initializing the population
def initialize_pop(programs, time_slots):
if not programs:
return [[]]
all_schedules = []
for i in range(len(programs)):
for schedule in initialize_pop(programs[:i] + programs[i + 1:], time_slots):
all_schedules.append([programs[i]] + schedule)
return all_schedules
# selection
def finding_best_schedule(all_schedules):
best_schedule = []
max_ratings = 0
for schedule in all_schedules:
total_ratings = fitness_function(schedule)
if total_ratings > max_ratings:
max_ratings = total_ratings
best_schedule = schedule
return best_schedule
# calling the pop func.
all_possible_schedules = initialize_pop(all_programs, all_time_slots)
# callin the schedule func.
best_schedule = finding_best_schedule(all_possible_schedules)
############################################# GENETIC ALGORITHM #############################################################################
# Crossover
def crossover(schedule1, schedule2):
crossover_point = random.randint(1, len(schedule1) - 2)
child1 = schedule1[:crossover_point] + schedule2[crossover_point:]
child2 = schedule2[:crossover_point] + schedule1[crossover_point:]
return child1, child2
# mutating
def mutate(schedule):
mutation_point = random.randint(0, len(schedule) - 1)
new_program = random.choice(all_programs)
schedule[mutation_point] = new_program
return schedule
# calling the fitness func.
def evaluate_fitness(schedule):
return fitness_function(schedule)
# genetic algorithms with parameters
def genetic_algorithm(initial_schedule, generations=GEN, population_size=POP, crossover_rate=CO_R, mutation_rate=MUT_R, elitism_size=EL_S):
population = [initial_schedule]
for _ in range(population_size - 1):
random_schedule = initial_schedule.copy()
random.shuffle(random_schedule)
population.append(random_schedule)
for generation in range(generations):
new_population = []
# Elitsm
population.sort(key=lambda schedule: fitness_function(schedule), reverse=True)
new_population.extend(population[:elitism_size])
while len(new_population) < population_size:
parent1, parent2 = random.choices(population, k=2)
if random.random() < crossover_rate:
child1, child2 = crossover(parent1, parent2)
else:
child1, child2 = parent1.copy(), parent2.copy()
if random.random() < mutation_rate:
child1 = mutate(child1)
if random.random() < mutation_rate:
child2 = mutate(child2)
new_population.extend([child1, child2])
population = new_population
return population[0]
##################################################### RESULTS ###################################################################################
# brute force
initial_best_schedule = finding_best_schedule(all_possible_schedules)
rem_t_slots = len(all_time_slots) - len(initial_best_schedule)
genetic_schedule = genetic_algorithm(initial_best_schedule, generations=GEN, population_size=POP, elitism_size=EL_S)
final_schedule = initial_best_schedule + genetic_schedule[:rem_t_slots]
print("\nFinal Optimal Schedule:")
for time_slot, program in enumerate(final_schedule):
print(f"Time Slot {all_time_slots[time_slot]:02d}:00 - Program {program}")
print("Total Ratings:", fitness_function(final_schedule))
优化度假小屋租赁收入
简要了解收益管理科学及其 Python 演示
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 3 月 8 日
–
图片由 Evelyn Paris 提供,来源于 Unsplash
如果你曾经购买过机票,你一定意识到时间对价格的影响。一个提前六个月购票的休闲旅客可能只需支付$200,而一个前一天才买票的商务旅客则需支付$3,300。类似的情况也会出现在我们预订酒店房间、租车或安排高尔夫球场的开球时间时。如果你曾经想知道背后的原理以及它是如何运作的,答案就是收益管理。
收入管理是一个数据驱动的企业,将客户细分、预测分析与机器学习、实验设计和数学优化以协调的方式结合起来,以最大化利用现有容量获得的收入。通过理解收入管理的原则,数据科学家可以运用他们的技能帮助公司和客户实现收入的显著增长。
收入管理的科学
收入管理(RM)是预测微观市场层面上实时客户需求的科学,并优化产品或服务的价格和可用性[1]。RM 由美国航空公司在 1980 年代首次提出,回应了新兴低票价竞争对手 People Express 的威胁。美国航空的 RM 系统在休闲和商务乘客之间区分了价格。它还使用优化算法来决定多少座位开放给早期预订的低票价乘客,多少座位保护给后期预订的高票价商务乘客。这种方法非常成功,恢复了美国航空的盈利能力,同时最终导致了 People Express 的关闭。因此,许多其他行业也开始采用 RM。
RM 的目标是通过正确的渠道在正确的时间以正确的价格向正确的客户提供合适的产品或服务。传统上,RM 是为在少数条件下运营的企业开发的:
-
固定容量: 例如,航空公司有固定的机队规模,酒店有固定数量的房间。
-
易腐产品或服务: 例如,某一日期的未售座位或酒店房间无法在以后销售。
-
客户提前预订资源(例如,预订航班或酒店房间)
如今,RM 技术正在超越这些假设,适用于电子商务等新兴行业。然而,总体而言,RM 涉及四个相互关联的活动:
关键的收入管理活动
1. 市场细分
首先的活动是根据使用模式或需求识别不同的客户群体。例如,航空公司将客户分为早期预订的休闲乘客和后期预订的商务乘客,而租车公司则根据客户是否需要紧凑型、中型或豪华车辆进行划分。
2. 确定价格响应
在进行细分后,我们必须确定价格响应,并根据情况选择最佳价格。如果我们将某个细分市场的价格定得过低,可能会轻松销售,但也会错失收入。相反,如果价格定得过高,我们可能会因顾客拒绝我们的报价而损失收入。因此,我们必须确定每个细分市场愿意支付的最佳价格,以及价格上涨对需求的影响。这通常通过定价实验来完成,也可以结合竞争对手和行业数据。实验应在真实市场条件下进行,并定期重复,因为通货膨胀、工资增长、假期和新竞争者等因素都可能影响价格响应。
3. 预测需求
为了开始应用收益管理,我们需要指定一个时间范围,然后预测该时间范围内每个细分市场在每个使用时段的需求。由于收益管理在微观经济层面运作,时间范围通常是短期的,并且持续时间较短。一个使用时段可能是航空行业的航班段,或者是酒店行业的周五至周日住宿。因此,例如,我们预测每个航班段的休闲和商务乘客的需求,或者每个可预订时间间隔的小房间和大房间的需求。
4. 优化容量分配
一旦我们估算了每个细分市场的需求,就需要确定如何分配我们的容量,以在整个时间范围内最大化收入。这告诉我们,例如,应该卖出多少座位给休闲乘客,留出多少座位给商务乘客,或者在每个时间段内要使用多少辆车来满足紧凑需求,多少辆车来满足豪华需求。这手动进行可能会非常困难,因为使用时段可能会重叠,导致相同资源无法同时使用。当有利时,还可以进行替代(例如,将大房间提供给请求小房间的客人),进一步增加了复杂性。因此,容量分配通常通过整数规划来完成。
这四项活动应该被视为关键要素,而不是过程中的严格步骤。一些收益管理系统主要关注容量分配,在销售期间之前固定价格,而其他系统则根据不断变化的市场条件频繁更新价格。这些活动需要根据每个应用的具体情况进行协调,以实现最佳的投资回报。
示例场景:优化度假小屋租赁收入
我们将展示一个 RM 活动,即容量分配,并展示它对收入的影响。这个示例可以在 Jupyter notebook 这里 查看。这个示例的源代码和数据文件可以在 这里** 找到**。
一家度假小屋租赁公司拥有以下资源:
-
3 个小型舱位
-
5 个中型舱位
-
3 个大型舱位
我们将假设我们已经确定了即将到来的长周末的最佳价格和准确的需求预测,如下所示:
目标是确定每种舱位类型分配到不同需求时间段的数量,以最大化收入。我们从导入关键库和读取数据开始:
建模问题
我们将制定并解决一个整数规划(IP)模型,将容量分配到需求预测中,以最大化获得的收入。这个模型将舱位分配到需求时间段,形式为(时间区间,舱位大小)对,并使用二进制指示器来指定允许的替代。我们从定义模型输入开始:
接下来,我们定义我们的决策变量:
优化模型现在可以表述如下:
在上述模型中,目标函数 (1) 只是所有收入在期间内的总和。约束条件 (2) 确保我们不会对任何舱位类型进行超额预订。我们通过确保对于任何舱位类型 i 和时间段 j,分配到时间段 j 或任何与 j 起始时间重叠的时间段的 i 类型舱位的总数不会超过可用的 i 类型舱位数量来实现这一点。约束条件 (3) 确保我们不会为任何时间段分配超过需求的舱位。约束条件 (4) 确保我们不会通过将舱位分配到无法满足的时间段而做出无效替换。最后,约束条件 (5) 确保决策变量的值为非负整数。
下面我们定义一个 Python 函数,从输入数据构建这个整数规划(IP)模型的实例:
获得最佳容量分配计划
现在我们将创建一个优化函数来解决模型,该函数将确定我们的最佳销售计划和获得的收入。为此,我们首先需要创建一些辅助函数,将原始数据转换为模型所需的格式。
现在我们可以优化并查看根据销售计划得到的收入。在这个示例中,我们将允许较大的舱位替代较小的舱位。
运行上述代码将得到如下结果:
有趣的是,当我们查看这个最优分配计划时,我们注意到经常发生将较大的舱位替换为较小舱位的情况。正如我们将看到的,允许这些类型的替换对收入有很大的影响。
以人为方式解决问题:先到先得
将最优政策与一个人可能在没有 RM 的情况下实施的政策进行比较是很有启发性的。在这种情况下,代理将根据请求的到达顺序(先到先得)简单地出售舱位预订。在这种情况下使用替换也没有多大意义,因为我们永远不知道下一个请求是否会是带来更多收入的较大舱位。
FCFS 产生的收入对请求的顺序非常敏感;这些请求可能以一种恰好把我们的舱位绑在非常不理想的方式的顺序到达,或者我们可能会运气好,得到一个接近最大收入的顺序。下面的代码实现了 FCFS 政策,并附带一个预处理函数,将原始数据转换为算法所需的形式。在预处理函数中,我们随机排列预订请求,以模拟它们在现实世界中的到达方式。
比较政策
下面我们将比较两种优化变体(一个不允许替换,另一个允许较大的舱位替换较小的舱位)与 FCFS 的收入影响。我们将运行 FCFS 100 次,每次随机排列预订请求,并将最差、中位(即典型)和最佳情况与两种优化结果进行比较。
运行这个会产生以下结果:
比较政策时,我们看到 FCFS 通常会留下未利用的收入——如果运气不好,这可能是一个相当大的数额。只有在最好的情况下,FCFS 才能达到在不允许替换的情况下的最大收入(这总是由优化 B 政策实现的)。然而,通过允许替换和优化,我们可以大幅提升收入——在最坏情况下增加超过 22%(或周末超过 4,000 美元)。此外,这额外的收入是利用现有容量赚取的,没有产生额外的成本,因此完全是利润。这一比较展示了 RM 的影响。
最后需要指出的是,有效的容量分配依赖于准确的需求预测,以作为优化输入。有些周我们的预测可能会失误。然而,假设预测通常可以 reasonably 精确,使用 RM 的系统将整体上优于不使用的系统。
结论
收入管理是一门能够显著提高现有容量收入的科学。通过将 RM 的元素纳入销售过程,公司可以开始在不产生额外运营成本的情况下增加收入。
除非另有说明,所有数据和图片均由作者提供。
参考文献
[1] Cross, R.B. (1997). 收入管理:市场主导的硬核策略. 纽约:百老汇书籍,第 276 页。
优化向量量化方法的机器学习算法
本文简要解释了我们在 ICASSP 2023 会议上发表的论文 [1]。有关更多详细信息,请查看此链接中的论文。
机器学习优化向量量化方法,用于神经网络的端到端训练
关注 发表在 Towards Data Science ·10 分钟阅读·2023 年 5 月 17 日
–
·
照片由 Alina Grubnyak 提供,发布于 Unsplash
向量量化
向量量化(VQ)是一种数据压缩技术,类似于 k-means 算法,可以对任何数据分布进行建模。向量量化已被广泛应用于语音、图像和视频数据,如图像生成 [2]、语音和音频编码 [3]、语音转换 [4,5]、音乐生成 [6] 和文本到语音合成 [7,8]。下图展示了向量量化(VQ)的工作原理。对于 VQ 过程,我们需要一个包括多个代码字的码本。对数据点(灰色点)应用 VQ 意味着将其映射到最近的代码字(蓝色点),即用最近代码字的值替换数据点的值。每个 Voronoi 单元(黑线)包含一个代码字,使得该单元中所有的数据点都将映射到该代码字,因为它是该 Voronoi 单元中数据点的最近代码字。
向量量化操作(作者提供的图像)
换句话说,向量量化通过以下公式将输入向量 x 映射到码本(CB)中的最近代码字:
VQ 的计算复杂度随着码本大小(VQ 比特率的增加)的增加而呈指数增长。因此,这种简单形式的 VQ 仅适用于有限的比特率(有限的码本大小)。为了解决这一挑战并将 VQ 应用于更高的比特率和更高维的数据,我们使用了 VQ 的一些变体,如残差 VQ、加法 VQ 和乘积 VQ。这些方法考虑了多个码本来对数据应用 VQ。我们将在下文中解释这三种 VQ 方法。
残差向量量化(RVQ)
残差 VQ 通过对输入向量 x 应用 M 个连续的 VQ 模块来进行量化。根据下图,假设 M=3。我们使用第一个码本(CB¹)对输入向量 x 应用第一个 VQ 模块。然后,在找到第一个码本中的最近代码字后,我们计算剩余值(R1)。接着,我们将 R1 作为输入传递给使用第二个码本(CB²)的下一个 VQ 模块。这个过程将持续 M 个阶段,其中我们会找到来自不同码本的三个最近代码字。最后,我们将输入向量 x 量化为 M 个最近代码字的总和。
加法向量量化(AVQ)
类似于残差 VQ,加法 VQ 通过应用 M 个连续的 VQ 模块来量化输入向量 x。然而,加法 VQ 采用复杂的束搜索算法来找到量化过程中的最接近代码字(你可以在这篇论文 [9] 中找到束搜索算法的详细信息)。根据下图,我们假设 M=3。在加法 VQ 中,我们首先从所有三个代码本的联合中搜索最接近的代码字(这里是 CB¹、CB²、CB³)。然后,假设我们从 CB² 找到最佳代码字。接下来,我们计算残差 (R1) 并将其作为输入传递给下一个 VQ 模块。由于第一个代码字是从 CB² 中选择的,现在我们在 CB¹ 和 CB³ 的联合中搜索最接近的代码字。在计算残差 R2 后,我们将其作为输入传递给最后的 VQ 模块,在这里我们使用尚未参与量化过程的最后一个代码本(在此例中是 CB¹)进行搜索。最后,我们将输入向量 x 量化为 M 个最接近代码字的和。
产品向量量化(PVQ)
产品 VQ 将维度为 D 的输入向量 x 拆分为 M 个独立的维度为 D/M 的子空间。然后,它对这些子空间应用 M 个独立的 VQ 模块。最后,产品 VQ 将输入向量 x 量化为 M 个最接近的代码字的连接(每个代码本一个)。下图展示了 M=3 时的产品 VQ。
代码本优化
向量量化(VQ)训练意味着优化代码本,使其以一种模型数据分布的方式来最小化数据点与代码本元素之间的量化误差(如均方误差)。为了优化上述三种 VQ 变体(残差 VQ、加法 VQ 和产品 VQ)的代码本,有不同的方法,我们将在后文中提到。
1. K-均值算法(传统方法):
根据文献综述,在大多数论文中,这三种 VQ 方法的代码本都是通过 K-均值算法 进行优化的。
2. 随机优化(机器学习算法):
机器学习优化算法是基于梯度计算的。因此,使用机器学习优化来优化向量量化方法是不可能的,因为向量量化函数中的 argmin 函数(上面的第一个方程)是不可微的。换句话说,我们无法在反向传播中将梯度传递过向量量化函数。这里我们提到了两种解决这个问题的方法。
2.1. 直接估计器(STE)
STE [10] 通过在反向传播过程中简单地将梯度完整地复制到 VQ 模块中来解决问题。因此,它没有考虑向量量化的影响,从而导致梯度与 VQ 函数的真实行为之间的不匹配。
2.2. 矢量量化中的噪声替代 (NSVQ):
NSVQ 技术 [11] 是我们最近提出的方法,其中通过向输入向量添加噪声来模拟矢量量化误差,使得模拟噪声获得原始 VQ 误差分布的形状(你可以简要阅读关于 NSVQ 的内容 在这篇文章中)。
NSVQ 技术 [11] 相较于 STE 方法 [10] 具有以下优势。1) NSVQ 为 VQ 函数提供更准确的梯度。2) NSVQ 实现了 VQ 训练(代码本优化)的更快收敛。3) NSVQ 无需对 VQ 训练进行额外的超参数调整(不需要在全局优化损失函数中添加额外的损失项)。
实验
在我们的论文中,我们使用了我们最近提出的 NSVQ 技术 [11] 来优化上述三种 VQ 变体。为了评估这三种 VQ 方法的性能,并研究它们在准确性、比特率和复杂性之间的权衡,我们进行了四种不同场景的实验。我们将在以下部分解释这些实验场景。
1. 近似最近邻(ANN)搜索
在这个实验中,我们通过在 SIFT1M 数据集 [12](128-D 图像描述符)上训练三种 VQ 方法来建模其分布。SIFT1M 图像描述符数据集 [12] 包括 10⁶ 个基向量、10⁵ 个学习向量和 10⁴ 个测试用的查询向量。实际的最近邻集合从基向量到查询向量。在 ANN 搜索中,我们首先使用在学习集上训练的对应学习到的代码本来压缩基向量。然后,对于每个查询向量,我们通过进行穷尽搜索来从压缩的基向量中找到近似的最近邻。为了评估数据压缩的质量,我们在不同的 T 参数值下计算 召回率度量,该度量显示实际最近邻(从实际数据中)是否存在于前 T 个计算的最近邻中。下图展示了使用我们提出的 NSVQ 技术优化的三种 VQ 变体与基线方法在 召回率度量 下的比较。一般来说,所有三种基于机器学习优化的 VQ 方法在召回率值上与基线相当(在 RVQ 的情况下甚至略好)。
比较我们提出的 VQ 方法和基线方法在 64 位(每个 8 个代码本,每个有 256 个码字)下对 SIFT1M 数据集的压缩召回值;Recall@T 显示实际最近邻(从实际数据中)是否存在于 T 个计算的最近邻中。(图片由作者提供)
2. 使用 VQ-VAE 的图像压缩
在这个实验中,我们在 CIFAR10 数据集的训练集上训练了一个向量量化变分自编码器(VQ-VAE)以进行压缩。为了在 VQ-VAE 的瓶颈中应用向量量化,我们使用了这三种 VQ 方法中的每一种。训练后,我们使用训练好的编码器、解码器和学习到的码本重建了 CIFAR10 的测试图像。为了评估重建图像的质量,我们使用了峰值信噪比(Peak SNR)指标。此外,我们使用加权每秒百万操作(WMOPS)指标计算了每种 VQ 方法的复杂度,该指标符合ITU-T 标准。下图展示了这个实验的结果。
在图像压缩场景下,提出的 VQ 方法在 15k 训练批次和 10 次单独实验中的峰值 SNR 和复杂度;线条表示均值,对应的填充区域表示其 95%分位数。对于所有 VQ 比特率,我们使用了四个码本,即 M=4。(图像由作者提供)
根据复杂度图(右侧),我们发现,对于相同的计算资源使用(左侧垂直红线)和较高的比特率,乘积 VQ 比残差 VQ 表现更好。此外,对于相同的计算资源使用(右侧垂直红线)和较高的比特率,残差 VQ 比加法 VQ 表现更好。因此,根据可用的计算资源,我们可以得出最适合使用的 VQ 方法。
3. 语音编码
在这个实验中,我们使用[13]中提出的语音编解码器,通过三种 VQ 方法对语音信号的谱包络进行建模。为了评估解码后语音信号的质量,我们使用了语音质量感知评估(PESQ)和感知加权信噪比(pSNR)作为客观指标。下图展示了所有三种 VQ 方法在 PESQ 和 pSNR 标准下的表现。根据结果,我们观察到,在这两个指标中,加法 VQ 的均值更高且方差更低,相比于残差 VQ 和乘积 VQ。
提出的 VQ 方法在语音编码场景下,针对 16 位 VQ(具有 4 个码本,即 M=4)在总体比特率为 8, 9.6, 13.2, 16.4, 24.4 和 32 kbit/s 时,PESQ 和 pSNR 指标的性能;实线表示 PESQ 和 pSNR 的均值,对应的填充区域表示其 95%分位数。(图像由作者提供)
4. 示例
在这个实验中,我们打算比较三种 VQ 方法在数据相关性方面的表现。因此,我们准备了两个维度为 64 的相关和不相关的数据集。然后,我们使用这三种 VQ 方法对这些数据集进行压缩。为了评估性能,我们计算了每个数据集及其量化版本之间的均方误差(MSE)。下图展示了这个实验的结果。
使用三种提出的 VQ 方法(数据维度=64,所有 VQ 比特率使用了四个码本,即 M=4)对相关和无关数据集进行的向量量化误差。(图片由作者提供)
在相关数据集的情况下,由于残差 VQ 和加法 VQ 考虑了所有数据维度之间的相关性,它们的量化误差远低于乘积 VQ,符合预期。另一方面,对于无关的数据,乘积 VQ 的表现优于加法 VQ 和残差 VQ,因为数据维度之间没有相关性,这正是乘积 VQ 的假设。
结论
使用诸如残差 VQ、加法 VQ 和乘积 VQ 等向量量化(VQ)变体,允许将 VQ 应用于高比特率和高维数据。这些 VQ 方法目前已经通过经典的期望最大化和 k-均值算法进行了优化。在本文中,我们通过机器学习优化来优化这些 VQ 方法,采用了我们最近提出的向量量化噪声替代(NSVQ)[11]技术。此外,NSVQ 还允许在神经网络中对 VQ 方法进行端到端优化。我们还研究了这三种 VQ 方法之间比特率、准确性和复杂性的权衡。因此,我们的开源实现[14]有助于为特定用例选择最佳的 VQ 方法。
GitHub 存储库
我们在以下网页提供了这些 VQ 方法的 PyTorch 实现。
## GitHub - MHVali/Additive-Residual-Product-Vector-Quantization-Methods
通过创建一个帐户来为 MHVali/Additive-Residual-Product-Vector-Quantization-Methods 的开发做出贡献…
致谢
特别感谢我的博士项目导师Prof. Tom Bäckström,他支持了我并为这项工作作出了贡献。
参考文献
[1] M. H. Vali 和 T. Bäckström,“向量量化方法在语音和图像处理中的随机优化”,载于 ICASSP 会议论文集,2023 年。
[2] A. Razavi、A. van den Oord 和 O. Vinyals,“使用 VQ-VAE-2 生成多样化的高保真图像”,载于 NeurIPS 会议论文集,2019 年。
[3] C. Gârbacea、A. van den Oord、Y. Li、F. S. C. Lim、A. Luebs、O. Vinyals 和 T. C. Walters,“使用 VQ-VAE 和 Wavenet 解码器的低比特率语音编码”,载于 ICASSP 会议论文集,2019 年。
[4] B. van Niekerk、L. Nortje 和 H. Kamper,“用于零语音 2020 挑战中的声学单元发现的向量量化神经网络”,载于 Interspeech 会议论文集,2020 年。
[5] S. Ding 和 R. Gutierrez-Osuna,“用于非平行语音转换的向量量化变分自编码器的群体潜在嵌入,”见于 Interspeech 会议录,2019 年。
[6] P. Dhariwal, H. Jun, C. Payne, J. W. Kim, A. Radford 和 I. Sutskever,“Jukebox: 一种音乐生成模型,” arXiv 预印本 arXiv:2005.00341,2020 年。
[7] A. Tjandra, B. Sisman, M. Zhang, S. Sakti, H. Li 和 S. Nakamura,“VQVAE 无监督单元发现及多尺度 code2spec 逆变器用于 Zerospeech 挑战 2019,”见于 Interspeech 会议录,2019 年。
[8] X. Wang, S. Takaki, J. Yamagishi, S. King 和 K. Tokuda,“用于统计参数语音合成的向量量化变分自编码器 (VQ-VAE) 自回归神经 F0 模型,” IEEE 音频、语音与语言处理学报,2020 年。
[9] A. Babenko 和 V. Lempitsky,“极端向量压缩的加性量化,”见于 CVPR 会议录,2014 年。
[10] Y. Bengio, N. Léonard 和 A. Courville,“通过随机神经元估计或传播梯度以进行条件计算,” arXiv 预印本 arXiv:1308.3432,2013 年。
[11] M. H. Vali 和 T. Bäckström,“NSVQ: 机器学习中向量量化的噪声替代,” IEEE Access,第 10 卷,2022 年。
[12] H. Jegou, M. Douze 和 C. Schmid,“用于最近邻搜索的产品量化,” IEEE 计算机学会模式分析与机器智能学报,第 33 卷,第 1 期,页码 117–128,2010 年。
[13] M. H. Vali 和 T. Bäckström,“端到端优化的多阶段光谱包络向量量化用于语音和音频编码,”见于 Interspeech 会议录,2021 年。
[14] gitlab.com/speech-interaction-technology-aalto-university/vq-variants
在 Mac 上优化 VS Code 以适应 dbt
在 Visual Studio Code 中最大化你的 dbt 生产力指南
·
关注 发表在 Towards Data Science ·9 分钟阅读·2023 年 4 月 5 日
–
(图片来自Unsplash)
如果你在使 VS Code 和 dbt 良好协同工作方面遇到困难,你并不孤单。将它们集成可能具有挑战性,但它将提高你的建模效率。这就是我分享对我有效的设置的原因。
在本文中,我将涵盖诸如升级终端以便快速回忆命令、利用可以更快构建模型的扩展,以及设置格式化和检查以清理代码和发现 SQL 中的错误等主题。
如果你实施本文中的提示,你将减少在输入代码、运行模型、清理代码和寻找错误上浪费的时间。这些节省下来的时间你可以用来创建增加价值的数据模型。虽然我在写这篇文章时以 Mac 用户为目标,但许多这些提示对 Windows 用户也同样适用。
开始所需:
-
Visual Studio Code
-
homebrew
包管理器。安装说明 在这里 -
Python 3.7 或更高版本以及安装了
dbt
的虚拟环境。有关详细信息,请参阅官方文档。 -
一个 dbt 项目。本文不详细介绍如何设置 dbt 项目。如果你没有这方面的经验,我建议你首先完成官方的 dbt jaffle shop 教程。
让我们深入了解吧!
1. 优化你的终端
你可以改善你内部的 VS Code 终端,使其看起来和行为更像iTerm。安装Oh-My-Zsh和一些附加软件包可以解锁以下好处:
-
自动补全,在你想快速调用
dbt run -m {specific_model_you_ran_a_while_ago}
时非常有用 -
Git 分支可视化
-
语法高亮,例如检查你输入的命令是否存在于
$PATH
中
如果你没有zsh
(较新的 Mac 已预装),请运行:
brew install zsh
运行以下命令来安装 Oh-My-Zsh:
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
现在你可以选择一个主题。我最喜欢的是Agnoster,因为它具有良好的 git 分支可视化。选择主题可以在你家目录中的 zsh 配置文件中完成。通过运行以下命令打开这个文件:
code ~/.zshrc
修改ZSH_THEME
以设置你喜欢的主题。保存并关闭。.zshrc
中的更改将在你重新启动终端时显示。
注意 要刷新当前终端窗口的设置,你可以运行:
*source ~/.zshrc*
Git 分支可视化使用了一些特定的图标,你可以通过所谓的“Nerd Font”在终端中显示这些图标。我喜欢 Dejavu Sans Mono Nerd Font,你可以使用homebrew
安装它[1]:
brew tap homebrew/cask-fonts && brew install --cask font-dejavu-sans-mono-nerd-font
接下来,确保你的 VS Code 终端使用新字体。转到命令面板(Cmd + Shift + P)并通过输入open settings打开settings.json
。在文件中添加以下行:
"terminal.integrated.fontFamily": "DejaVuSansMono Nerd Font",
注意: 确保选择的是通用 用户设置 文件,而不是项目特定的 工作区设置 文件。稍后,当配置与 dbt 相关的设置时,我们将使用工作区设置。
你的 VS Code 终端现在应该看起来像这样:
(作者截图)
最后的步骤是安装zsh-autocompletion
和zsh-syntax-highlighting
软件包[2],[3]。通过运行以下命令来安装它们:
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions && \
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
将它们添加到~/.zshrc
文件中的plugins
变量中:
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
终端的最终说明: 我简要提到过 iTerm,这是默认 macOS 终端的一个替代品。如果你还没有安装,建议你试试。
2. 扩展
到目前为止,我们已经涵盖了一些通用 VS Code 设置的优化。现在是时候讨论一些更具体的 dbt 配置,从一个非常强大的 VS Code 扩展开始,叫做 dbt Power User。
[## dbt Power User - Visual Studio Marketplace
这个扩展使得 vscode 可以无缝地与 dbt 配合使用。
marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=innoverio.vscode-dbt-power-user&source=post_page-----a56dd27ba8d5--------------------------------)
我常用的这个扩展的主要功能有:
-
预览编译后的 SQL (Cmd + ‘)
-
从源
yml
生成基础模型 -
模型引用的自动补全
-
执行模型特定的测试
SQL & Jinja 模板化
SQL 模板化的概念(图片来源于作者)
dbt 成为如此强大的数据建模工具的原因之一是它支持 模板化 SQL 代码,使用 Jinja。它允许你使用类似 Python 的逻辑(例如 if 语句或 for 循环)来创建 SQL 查询。实际中,一个 模板化的 SQL 文件可能看起来像这样:
-- Set Variable
{% set min_date = '2021-01-01' %}
-- Use Variable
select *
from {{ ref('model_x') }}
where date > '{{min_date}}'
编译器返回以下 编译后的 SQL 文件:
select *
from `your_project`.`your_dataset`.`model_x`
where date > '2021-01-01'
编译后的查询结果可能会因使用的数据库不同而有所不同。模板化允许你用几行代码构建复杂的 SQL 模型。
dbt 中的模板化是一个独立的话题,要了解如何在模型中使用模板化,请参考 官方文档。
vscode-dbt
一个可以帮助你加速编写 Jinja 模板化 SQL 代码的工具是 vscode-dbt
。这个扩展为你的工具集添加了一些有用的 Jinja 片段。例如:for
,ref
和 source
。
[## vscode-dbt — Visual Studio Marketplace
提供 dbt 和 Jinja 函数在 SQL、YAML 和 Markdown 中的片段。酷炫提示:为了实现更丰富的集成……
marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=bastienboutonnet.vscode-dbt&source=post_page-----a56dd27ba8d5--------------------------------)
为了使片段被识别,你需要在工作区的 settings.json
中添加以下行:
"files.associations": {
"*.sql": "jinja-sql"
},
添加自定义片段
创建自定义代码片段(图片来源于作者)
如果你发现自己不断重复输入相同的内容,创建自定义片段会更快。例如;在每个模型的顶部,我喜欢导入所有的依赖项作为 CTE,这类似于你在 Python 代码中所做的。
你可以通过打开命令面板(Cmd + Shift + P)并导航到配置用户片段来添加代码片段。
转到配置用户片段(截图由作者提供)
打开 jinja-sql 片段文件(截图由作者提供)
添加片段后,文件会变成这样:
{
"Generate Import" : {
"prefix" : "import",
"body" : "$1 as (select * from {{ ref($2) }})"
}
}
这是一个简单的示例,仅用于说明目的,我鼓励你在适合的地方创建自己的代码片段。
3. Jinja-SQL 格式化:sqlfmt
为了格式化 Jinja-SQL,我使用 sqlfmt
,这是一个专门为 dbt 逻辑设计的格式化工具,并与 dbt Power User 集成。sqlfmt
是一个有主见的格式化工具,你可以调整最大行长度,但在自定义格式化行为方面没有太多空间。这是好事,因为它减少了关于代码美学的讨论空间。这种简单性也对格式化的可靠性和速度产生了积极影响。
安装 sqlfmt
的最简单方法(如 [4] 中推荐的)是使用 pipx
。
如果你没有 pipx
,请运行以下命令安装:
brew install pipx
ensurepath
命令将 pipx
的二进制文件添加到你的 PATH
中,这样你就可以从任何地方调用 sqlfmt
→ 在我们的例子中就是从 dbt Power User。
pipx ensurepath
要安装 sqlfmt
,请运行:
pipx install 'shandy-sqlfmt[jijnafmt]'
结果是 sqlfmt
被安装并暴露在你的 $PATH
中:
(图片由作者提供)
最后的步骤是在 VS Code 中配置 Jinja SQL 的默认格式化工具。将以下几行添加到工作区的 settings.json
中:
"[jijna-sql]": {
"editor.defaultFormatter": "innoverio.vscode-dbt-power-user"
"editor.formatOnSave": false //Optional, I prefer to format manually.
}
注意: 如果你阅读 sqlfmt 的手册,你会发现可以通过使用
*# fmt: off / on*
来排除代码的某些部分,或者定义要排除的文件夹。
4. 使用 SQLFluff 进行 Linting
使用 SQLFluff 进行 Linting(图片由作者提供)
什么是 Linting?
Linting 是分析代码以查找错误、漏洞和样式问题的过程。你可以将 lint 工具视为代码的语法检查器。
格式化和 linting 是两种不同的技术,可以一起使用来改进你的代码。格式化将改善代码的外观,而 linting 将帮助你捕捉代码中的更复杂的错误和问题。
我使用的 linter 是 SQLFluff。它适用于大多数 SQL 方言,更重要的是;它与 Jinja 模板化的 SQL 兼容。在撰写时,我发现这是最先进的 SQL linter。
通过运行以下命令安装:
brew install sqlfluff
要将 SQLFluff 与 VS Code 集成,你可以下载sqlfluff 扩展。为避免sqlfluff
、sqlfmt
和 dbt Jinja 逻辑之间的冲突,请在你的工作区settings.json
中添加以下行:
"sqlfluff.dialect": "bigquery", // Depends on your database
"sqlfluff.executablePath": "/usr/local/bin/sqlfluff", // Check your path by running `which sqlfluff`
"sqlfluff.linter.diagnosticSeverity": "error",
"sqlfluff.linter.run": "onSave",
"sqlfluff.config": "${workspaceFolder}/.sqlfluff",
此外,在你的工作区文件夹中创建一个.sqlfuff
文件。添加以下行:
[sqlfluff]
# Double, but needed if you run from terminal
dialect = bigquery
exclude_rules = L003, L018, L022, L036,
# Conflict sqlfmt:
# L003 : Indentation
# L018 : CTE closing bracket position
# L036 : Amount of columns in one line (1 vs. how many fit within line length)
# Personal Preference
# L022 : Blank line after CTE
# (conflicts with creating select * cte's of dependencies at the top of a file)
[sqlfluff:rules]
# Use the same linelength as sqlfmt to prevent conflicts
max_line_length = 100
capitalisation_policy = lower
extended_capitalisation_policy = lower
[sqlfluff:indentation]
indented_joins = False
indented_using_on = True
template_blocks_indent = False
[sqlfluff:rules:L052]
multiline_newline = True
注意: 上述提到的
*.sqlfluff*
基于 [5] 但稍作扩展。如何设置 linting 规则取决于你的个人(以及你团队的)偏好。
安装了vscode-sqlfluff
扩展后,你无需再从命令行进行 lint 检查。然而,我仍然喜欢从终端调用sqlfluff lint {path_to_model_sub_folder}
,因为它能清楚地概览你代码中的任何问题。
(截图由作者提供)
除了lint
命令,SQLFluff 还有一个类似格式化的fix
命令。你可以使用它来修复 SQLFluff 提出的一些代码问题。然而,我建议谨慎使用此功能,因为它也可能会破坏你的代码。如前所述,我倾向于使用较少复杂的sqlfmt
进行格式化。
为了忽略由于模板化 dbt 函数导致的 lint 错误,比如下面的那个,你可以在.sqlfluff
文件中添加ignore = templating
。
L: 25 | P: 15 | TMP | Undefined jinja template variable: 'dbt_utils'
要进一步自定义 linting 行为,请参阅官方文档[6]。
完成!
结论
如果你按照步骤操作并且已经完成了这些,应该现在你有了一个优化的工作区来使用 dbt!我们涵盖了如升级 VS Code 终端、使用哪些扩展以及如何格式化和 lint Jinja 模板化 SQL 等主题。这些提示将节省你的时间和精力,让你专注于构建更好的数据模型。
目标是不断提高效率。如果你在寻找下一个更高级的步骤,我建议你探索dbt 的宏。如果你有任何问题或对未来的文章有想法,请随时联系我。
感谢阅读,祝建模愉快!
觉得这篇文章有用吗?
如果你觉得这篇文章有帮助,记得 关注我 以便了解我最新的关于数据建模、编码等的文章。
参考文献
[1] 通过 homebrew 安装字体 | | 链接
[2] 安装 zsh-syntax-higlighting | | 链接
[3] 安装 zsh-autocomplete | | 链接
[4] 安装 sqlfmt | | 链接
[5] 集成sqlfmt
和sqlfluff
| | 链接
[6] 默认配置 SQLFluff | | 链接
优化您的策略:超越 A/B 测试的方法
对经典 A/B 测试的深入解释:Epsilon-greedy、Thompson Sampling、Contextual Bandits 等
·
关注 发布于 Towards Data Science · 7 分钟阅读 · 2023 年 7 月 24 日
–
在数字营销的世界中,竞争激烈。每家公司都希望制定出最高价值的营销策略——提升客户留存、增强客户满意度,或实现其他商业目标。然而,并没有一种完美的营销策略适用于所有客户。相反,我们可以努力找到改进版的营销策略。这就是 A/B 测试的用武之地。
简而言之,A/B 测试是一种实验,它通过随机将观众分成两组,并比较两种策略的版本来查看哪一种表现更好。这是做出数据驱动决策的传统且经过统计验证的方法。
何时使用 A/B 测试
想象你和你的伙伴经营了一段时间的玩具电子商店。一天,你的伙伴对着陆页的背景颜色提出了担忧。他认为当前的颜色未能有效推动访客的转化率(即购买商品的访客百分比)。
-
当前使用(策略 A):橙色
-
你的伙伴的建议(策略 B):黄色
你立即识别出 A/B 测试是一个很好的实验设计。因为相对直接地根据测量结果(即每个策略的转化率)来确定“赢家”或“输家”,展示了哪一版本的网站最吸引访客。
下图展示了示例情况。
A/B 测试(图像由作者提供)
A/B 测试的问题是什么
A/B 测试的结果确认,黄色着陆页(策略 B)上的网站访问者在电子商店中的转化率更高。因此,你决定将黄色着陆页推向所有访客群体。然而,你很快意识到这种测试方法带来了成本:由于 A/B 测试有严格定义的开始和结束点,整个测试期间,半数流量被分配给表现较差的策略 A。
选择较差策略 A 和最理想选项(在这种情况下为策略 B)之间的成本差异可以用遗憾来表示。
A/B 测试中的遗憾成本(图像由作者提供)
这个 A/B 测试可以总结为以下特点:
-
这强调了探索的价值:收集足够的信息来确定策略 A 还是 B 在整个测试中是最优的。
-
这超越了利用的价值:在测试过程中不通过减少流量来实现更高的转化率,即使收集到的信息表明策略 B 的表现更好。
听到 A/B 测试的结果后,你的伙伴对他建议更好策略的成功感到兴奋。他想进一步了解其他背景颜色,包括绿色、红色和紫色,是否能提高转化率。你可以预见到使用当前测试方法的情况会变得更糟,因为当涉及到更多策略时,A/B 测试的遗憾可能会更高。
多种策略在 A/B 测试中的情况(图像由作者提供)
多种策略在 A/B 测试中的遗憾成本(图像由作者提供)
超越 A/B 测试
我们欢迎新策略,因为它们可能使我们更接近商业目标。然而,我们通常会怀疑是否在测试过程中浪费了大量有限的时间和资源在糟糕的策略上。
在经典的 A/B 测试中,我们保持在探索模式,忽略了策略是否表现优异或较差。 如果我们采取相反的方式,专注于利用会怎样呢?操作方式如下:当只有少数访客在电子商店进行购买时,我们会快速选择看似最优的策略,然后在测试剩余时间里将所有访客都引导到这个策略上。这种方法具有较高的风险,因为样本量可能不足以根据策略的表现得出正确的结论。
高度利用(图片由作者提供)
高度利用的遗憾成本(图片由作者提供)
现在,我们理解了单独的探索或单独的利用都不是好的主意。我们需要在探索和利用之间取得平衡。此外,无可争议的是,我们应该在测试过程中不断将更多流量分配给当前最优策略。
换句话说,我们更倾向于采取能够最大化估计回报的行动。
Epsilon-Greedy
Epsilon-Greedy 算法基于类似的理念,通过在每位访客的流量中随机选择探索和利用。假设我们希望 90%的流量集中于利用,10%的流量用于探索。算法遵循以下步骤:
当访客到达电子商店时,
-
确定当前的最优策略
-
90%的机会进行利用:将访客引导至当前最优策略。或者,有 10%的机会进行探索:将访客引导至剩余策略中的一个。
随着时间的推移,我们贪婪地在不同的策略中进行选择以进行利用,并逐渐做出一个一致的当前最优策略的决策。然而,我们并不会放弃尝试其他策略的机会,即使在测试接近最终阶段时也是如此。 通过在每次行动中最大化估计的回报,这种方法的总体遗憾成本可能比经典的 A/B 测试要小。
Epsilon-Greedy(图片由作者提供)
托普森采样
Epsilon-Greedy 看起来很有前景,但它是否还能进一步改进?让我们考虑两个场景:
(A) 当只有少数访客到达时,我们能否自信地为整个群体确定最优策略?
可能不能。我们担心这些样本可能是异常值。由于仍然存在较高的不确定性,探索仍然是必要的。
(B) 当成千上万的访客到达或我们处于测试的后期阶段时,我们现在是否有更大的信心?
最有可能的是,由于更多的样本提供了更高的统计能力,使我们能够识别出在真实人群中表现优于其他策略的策略。
现在,让我们优化我们的策略。为了根据我们的信心水平在探索和利用之间做出选择,我们可以实施汤普森采样。
这种方法选择具有最高可能性将访客转化为买家的策略,而不是仅仅专注于最大化预期奖励。 每次访客访问电子商店时,这种方法都会跟踪每种策略的总买家和非买家数量,并构建它们各自的贝塔分布。其优点包括:
-
当样本量较小时,即使是转化率较低的策略也可能被选择(优先考虑探索)。
-
随着证据变得更加明显,具有更高转化率的策略将在大多数情况下被选择(优先考虑利用)。
上下文赌博机
汤普森采样是一种有效的方法,因为它在整个测试过程中动态调整探索和利用之间的平衡。虽然它允许我们在客户到达时做出有关应用哪种策略的明智决策,但这些决策不会针对不同的客户群体进行个性化。
照片由Justin Lim拍摄,发布在Unsplash上
让我们考虑两个访客:Peter 和 Mary。Peter 喜欢每个周末与孩子一起观看《神偷奶爸》的视频,而 Mary 对这些没有兴趣。即使在测试的早期阶段,我们也可以预测 Peter 更倾向于访问黄色的玩具电子商店页面(《神偷奶爸》的主要颜色!),并且更有可能进行购买。
这个例子突出了考虑访客上下文数据的重要性。通过利用上下文赌博机,我们可以更系统地应用算法决策。在现实场景中,上下文可能包括关于每位客户的历史数据,如网站点击、过去的购买、打开个性化邮件的频率,甚至是他们当前会话的数据,例如最近的搜索查询。然后,算法可以学习将不同的上下文与最有可能导致转化的策略关联起来。
任何新的创意
我们已经讨论了各种方法,包括经典 A/B 测试、Epsilon-Greedy 算法、汤普森采样和上下文赌博机。
-
经典的 A/B 测试:涉及保持在探索模式中。
-
Epsilon-Greedy 算法:随机平衡探索和利用。
-
汤普森采样:随着每种策略的样本量增加,更多强调利用。
-
上下文赌博机:根据访客的上下文(附加信息)提供个性化和最优的策略
这些方法在最近的研究论文中得到了进一步的精炼和讨论。以下是一些例子:
-
“具有深度表示和浅层探索的神经上下文强盗算法”:神经上下文强盗算法依赖于在整个网络参数空间中进行的探索,这对于大规模网络通常效率低下。该论文提出了一种具有浅层探索和设定遗憾界限的新算法。
-
“联邦上下文强盗算法的实证评估”:本文探讨了将联邦学习(一种去中心化的机器学习模型训练方法)与上下文强盗算法相结合的问题。它解决了与联邦学习整合相关的关注点,例如利用少量预训练数据。
在你离开之前
如果你喜欢这篇阅读,我邀请你关注 我的 Medium 页面。通过这样做,你可以随时了解有关数据科学、项目管理和自我提升的精彩内容。