Unión AsOf de DuckDB: búsqueda temporal difusa


descripción general

 

DuckDB admite AsOf Joins, un método para hacer coincidir valores cercanos. Son particularmente útiles para buscar tablas de eventos para análisis temporal.

¿Tiene datos de series temporales a los que desea unirse pero las marcas de tiempo no coinciden? ¿O desea utilizar el tiempo de otra tabla para encontrar valores que cambien con el tiempo? ¿Terminaste escribiendo uniones desiguales complejas (y lentas) para obtener el resultado? ¡Entonces este articulo es para usted!

¿Qué es una conexión AsOf?

Los datos de series temporales no siempre son completamente consistentes. Es posible que el reloj esté ligeramente atrasado o que haya un retraso entre la causa y el efecto. Esto puede crear desafíos al unir dos conjuntos de datos ordenados. AsOf Joins es una herramienta que soluciona este y otros problemas similares.

Uno de los problemas para resolver que se utiliza AsOf Joins es encontrar el valor de un atributo cambiante en un momento específico. Este caso de uso es tan común que su nombre proviene de:

Por favor dígame el valor actual de la propiedad.

Sin embargo, de manera más general, las uniones AsOf incorporan algunas semánticas de análisis temporal comunes que pueden ser engorrosas y lentas de implementar en SQL estándar.

Ejemplo de cartera

Comencemos con un ejemplo concreto. Digamos que tenemos una lista de precios de acciones con marca de tiempo:

corazón cuando precio
APLICAR 2001-01-01 00:00:00 1
APLICAR 2001-01-01 00:01:00 2
APLICAR 2001-01-01 00:02:00 3
MSFT 2001-01-01 00:00:00 1
MSFT 2001-01-01 00:01:00 2
MSFT 2001-01-01 00:02:00 3
GOOG 2001-01-01 00:00:00 1
GOOG 2001-01-01 00:01:00 2
GOOG 2001-01-01 00:02:00 3

Tenemos otra tabla con tenencias de cartera en diferentes momentos:

corazón cuando Comparte
APLICAR 2000-12-31 23:59:30 5.16
APLICAR 2001-01-01 00:00:30 2.94
APLICAR 2001-01-01 00:01:30 24.13
GOOG 2000-12-31 23:59:30 9.33
GOOG 2001-01-01 00:00:30 23.45
GOOG 2001-01-01 00:01:30 10.58
DATOS 2000-12-31 23:59:30 6.65
DATOS 2001-01-01 00:00:30 17,95
DATOS 2001-01-01 00:01:30 18.37

Podemos calcular el valor de cada retención en ese momento buscando el último precio antes de la marca de tiempo de retención usando AsOf Join:

SELECT h.ticker, h.when, price * shares AS value
FROM holdings h ASOF JOIN prices p
  ON h.ticker = p.ticker
 AND h.when >= p.when

Esto agregará el valor mantenido en ese momento a cada fila:

corazón cuando valor
APLICAR 2001-01-01 00:00:30 2.94
APLICAR 2001-01-01 00:01:30 48.26
GOOG 2001-01-01 00:00:30 23.45
GOOG 2001-01-01 00:01:30 21.16

Básicamente, realiza una función definida al encontrar valores cercanos en una lista de precios. También tenga en cuenta que los valores de teletipo que faltan no coinciden y no aparecen en el resultado.

Conexión externa AsOf

Dado que AsOf genera como máximo una coincidencia desde el lado derecho, la tabla de la izquierda no crecerá debido a las uniones, pero puede reducirse si faltan tiempos en el lado derecho. Para manejar esta situación se puede utilizar una conexión AsOf externa:

SELECT h.ticker, h.when, price * shares AS value
FROM holdings h ASOF LEFT JOIN prices p
  ON h.ticker = p.ticker
 AND h.when >= p.when
ORDER BY ALL

Como era de esperar, cuando no hay un ticker o tiempo antes de que comience el precio, esto producirá precios y valores NULL en lugar de eliminar la fila izquierda.

corazón cuando valor
APLICAR 2000-12-31 23:59:30
APLICAR 2001-01-01 00:00:30 2.94
APLICAR 2001-01-01 00:01:30 48.26
GOOG 2000-12-31 23:59:30
GOOG 2001-01-01 00:00:30 23.45
GOOG 2001-01-01 00:01:30 21.16
DATOS 2000-12-31 23:59:30
DATOS 2001-01-01 00:00:30
DATOS 2001-01-01 00:01:30

Alternativas de ventana

SQL estándar puede implementar este tipo de unión, pero requiere el uso de funciones de ventana y uniones de desigualdad. Todas estas operaciones pueden ser bastante costosas, pero la consulta se ve así:

WITH state AS (
  SELECT ticker, when, price,
    LEAD(when, 1, 'infinity') OVER (PARTITION BY ticker ORDER BY when) AS end
),
SELECT ticker, h.when, price * shares AS value
FROM holdings h INNER JOIN state s
  ON h.ticker = s.ticker
 AND h.when >= s.when
 AND h.when < s.end

El valor predeterminado de infinito se utiliza para garantizar que la última fila tenga un valor final que se pueda comparar. Para nuestro ejemplo, el estado CTE se ve así:

corazón precio cuando fin
APLICAR 1 2001-01-01 00:00:00 2001-01-01 00:01:00
APLICAR 2 2001-01-01 00:01:00 2001-01-01 00:02:00
APLICAR 3 2001-01-01 00:02:00 infinidad
GOOG 1 2001-01-01 00:00:00 2001-01-01 00:01:00
GOOG 2 2001-01-01 00:01:00 2001-01-01 00:02:00
GOOG 3 2001-01-01 00:02:00 infinidad
MSFT 1 2001-01-01 00:00:00 2001-01-01 00:01:00
MSFT 2 2001-01-01 00:01:00 2001-01-01 00:02:00
MSFT 3 2001-01-01 00:02:00 infinidad

En ausencia de condiciones de igualdad, el planificador tendrá que utilizar combinaciones de desigualdad, lo que puede resultar muy costoso. Incluso en el caso de condiciones de igualdad, la combinación hash resultante puede terminar con una larga cadena de claves de código idénticas que coincidirán y deberán eliminarse.

¿Por qué AsOf?

Si SQL ya puede calcular uniones AsOf, ¿por qué necesitamos un nuevo tipo de unión? Hay dos razones importantes: expresabilidad y rendimiento. La alternativa de ventana es más detallada y más difícil de entender que la sintaxis AsOf, por lo que hacer que sea más fácil decir lo que estás haciendo puede ayudar a otros (¡incluso a ti!) a entender lo que está pasando.

La sintaxis también facilita que DuckDB comprenda lo que desea y produzca resultados más rápido. Las versiones en ventana y de unión desigual pierden la valiosa información de que los intervalos no se superponen. También evita que el optimizador de consultas mueva la combinación porque SQL insiste en crear ventanas después de la combinación. Al tratar las operaciones como uniones con restricciones de datos conocidas, DuckDB puede mover uniones para mejorar el rendimiento y utilizar algoritmos de unión personalizados. El algoritmo que utilizamos es ordenar la tabla de la derecha y luego hacer algún tipo de combinación con los valores de la izquierda. Pero a diferencia de una combinación de combinación estándar, AsOf puede detener la búsqueda cuando se encuentra la primera coincidencia, ya que hay como máximo una coincidencia.

tabla de estado

可能想知道为什么WITH 子句中的公共表表达式被称为状态。 这是因为价格表实际上是时间分析中所谓的事件表的一个示例。 事件表的行包含时间戳和当时发生的事情(即事件)。 价格表中的事件是股票价格的变化。 事件表的另一个常见示例是结构化日志文件:日志的每一行记录“发生”某事的时间——通常是对系统一部分的更改。

事件表很难使用,因为每个事实只有开始时间。 为了知道事实是否仍然正确(或在特定时间正确),还需要结束时间。 具有开始时间和结束时间的表称为状态表。 将事件表转换为状态表是一项常见的时态数据准备任务,上面的窗口 CTE 显示了如何使用 SQL 来完成此任务。

哨兵值

窗口方法的一个限制是排序类型需要具有在不支持无穷大时可以使用的哨兵值(未使用的值或 NULL)。

这两种选择都可能存在问题。 在第一种情况下,确定上哨兵值可能并不容易(假设排序是字符串列?)在第二种情况下,您需要将条件编写为 h.when < s.end OR s.end IS NULL 并在连接条件中使用这样的 OR 会使比较变慢并且难以优化。 此外,如果排序列已使用 NULL 来指示缺失值,则此选项不可用。

对于大多数状态表,都有合适的选择(例如大日期),但 AsOf 的优点之一是,如果分析任务不需要状态表,它可以避免设计状态表。

事件表变体

到目前为止,我们一直在使用标准类型的事件表,其中时间戳被假定为状态转换的开始。 但 AsOf 现在可以使用任何不等式,这使其能够处理其他类型的事件表。

为了探索这一点,让我们使用两个非常简单的表,没有相等条件。 构建端只有四个带有字母值的整数“时间戳”:

Time Value
1 a
2 b
3 c
4 d

探测表只是时间值加上中点,我们可以制作一个表来显示每个探测时间匹配的值大于或等于:

Probe >=
0.5
1.0 a
1.5 a
2.0 b
2.5 b
3.0 c
3.5 c
4.0 d
4.5 d

这表明探测值匹配的区间处于半开区间[Tn,Tn+1)内。

现在让我们看看如果使用严格大于作为不等式会发生什么:

Probe >
0.5
1.0
1.5 a
2.0 a
2.5 b
3.0 b
3.5 c
4.0 c
4.5 d

现在我们可以看到探针值匹配的区间处于半开区间(Tn,Tn+1]。唯一的区别是该区间在末尾而不是在开头闭合。这意味着对于这种不等式类型 ,时间不是间隔的一部分。

如果不等式向另一个方向发展(例如小于或等于)怎么办?

Probe <=
0.5 a
1.0 a
1.5 b
2.0 b
2.5 c
3.0 c
3.5 d
4.0 d
4.5

同样,我们有半开间隔,但这次我们匹配前一个间隔 (Tn-1, Tn]。解释这一点的一种方法是构建表中的时间是间隔的结束时间,而不是开始时间 .此外,与大于或等于不同,间隔在末尾而不是在开始处闭合。将其添加到我们发现的严格大于的内容中,我们可以将其解释为意味着当非时查找时间是间隔的一部分 - 使用严格的不等式。

我们可以通过查看最后一个不等式来检查这一点:严格小于:

Probe <
0.5 a
1.0 b
1.5 b
2.0 c
2.5 c
3.0 d
3.5 d
4.0
4.5

在这种情况下,匹配间隔是[Tn-1, Tn)。 这是一个严格的不等式,所以表时间不在区间内,而且是一个小于,所以时间是区间结束的时间。

总而言之,以下是完整列表:

Inequality Interval
> (Tn, Tn+1]
>= [Tn, Tn+1)
<= (Tn-1, Tn]
< [Tn-1, Tn)

现在我们对不平等的含义有两种自然的解释:

  • • 大于(或小于)不等式意味着该时间是间隔的开始(或结束)。

  • • 严格(或非严格)不等式意味着时间被排除在(或包含在)间隔之外。

因此,如果我们知道时间是事件的开始还是结束,以及时间是包含还是排除,我们就可以选择适当的 AsOf 不等式。

用法

到目前为止,我们已经明确指定 AsOf 的条件,但 SQL 还针对两个表中列名相同的常见情况提供了简化的连接条件语法。 此语法使用 USING 关键字列出应比较相等性的字段。 AsOf 也支持此语法,但有两个限制:

  • • 最后一个字段是不等式

  • • 不等式为 >= (最常见的情况)

我们的第一个查询可以写成:

SELECT ticker, h.when, price * shares AS value
FROM holdings h ASOF JOIN prices p USING(ticker, when)

请注意,如果您没有在 SELECT 中显式列出列,则排序字段值将是探测值,而不是构建值。 对于自然连接,这不是问题,因为所有条件都是相等的,但对于 AsOf,必须选择一侧。 由于 AsOf 可以被视为查找函数,因此返回“函数参数”比函数内部更自然。

原理

AsOf 连接真正做的事情是允许将事件表视为连接操作的状态表。 通过了解连接的语义,它可以避免创建完整的状态表,并且比一般的不等式连接更有效。

让我们首先看看窗口版本是如何工作的。 请记住,我们使用此查询将事件表转换为状态表:

WITH state AS (
  SELECT ticker, when, price,
    LEAD(when, 1, 'infinity') OVER(PARTITION BY ticker ORDER BY when) AS end
),

状态表 CTE 是通过在代码上对表进行哈希分区、按时间排序,然后计算恰好向下移动 1 的另一列来创建的。 然后通过股票上的散列连接和时间上的两次比较来实现连接。

如果没有股票行情列(例如,单个商品的价格),那么将使用我们的不等式连接运算符来实现连接,该运算符将实现并对两侧进行排序,因为它不知道范围是不相交的。

AsOf 运算符使用所有三个运算符管道 API 来合并和收集行。 在接收阶段,AsOf 哈希分区并对右侧进行排序以生成临时状态表。 (事实上,它使用与 Window 相同的代码,但没有不必要地具体化结束列。)在运算符阶段,它会过滤掉(或返回)由于谓词表达式中的 NULL 值而无法匹配的行,然后进行哈希分区和 将剩余的行排序到缓存中。 最后,在源阶段,它匹配哈希分区,然后合并连接每个哈希分区内的排序值。

基准测试

由于 AsOf 连接可以使用标准 SQL 查询以多种方式实现,因此基准测试实际上是比较各种替代方案。

一种替代方法是名为 debug_asof_iejoin 的 AsOf 调试 PRAGMA,它使用 Window 和 IEJoin 实现连接。 这使我们能够轻松地在实现之间切换并比较运行时间。

其他替代方案结合了等连接和窗口函数。 等值连接用于实现等式匹配条件,窗口用于选择最接近的不等式。 我们现在将研究两种不同的窗口技术并比较它们的性能。 最重要的是,虽然 AsOf 连接有时会更快一些,但 AsOf 连接具有所有算法中最一致的行为。

窗口作为状态表

第一个基准测试将哈希连接与状态表进行比较。 它使用自连接探测由 100K 时间戳和 50 个分区键构建的 5M 行值表,其中仅存在 50% 的键,并且时间戳已移动到原始时间戳的中间位置:

CREATE TABLE build AS (
  SELECT k, '2001-01-01 00:00:00'::TIMESTAMP + INTERVAL (v) MINUTE AS t, v
  FROM range(0,100000) vals(v), range(0,50) keys(k)
);

CREATE TABLE probe AS (
  SELECT k * 2 AS k, t - INTERVAL (30) SECOND AS t
  FROM build
);

构建表如下所示:

k t v
0 2001-01-01 00:00:00 0
0 2001-01-01 00:01:00 1
0 2001-01-01 00:02:00 2
0 2001-01-01 00:03:00 3

探测表如下所示(k 只有偶数值):

k t
0 2000-12-31 23:59:30
0 2001-01-01 00:00:30
0 2001-01-01 00:01:30
0 2001-01-01 00:02:30
0 2001-01-01 00:03:30

基准测试只是进行连接并对 v 列求和:

SELECT SUM(v)
FROM probe ASOF JOIN build USING(k, t);

调试PRAGMA不允许我们使用散列连接,但我们可以再次在CTE中创建状态表并使用内连接:

-- Hash Join implementation
WITH state AS (
  SELECT k, 
    t AS begin, 
    v, 
    LEAD(t, 1, 'infinity'::TIMESTAMP) OVER (PARTITION BY k ORDER BY t) AS end
  FROM build
)
SELECT SUM(v)
FROM probe p INNER JOIN state s 
  ON p.t >= s.begin AND p.t < s.end AND p.k = s.k

这是有效的,因为规划器假设相等条件比不等式更具选择性,并使用过滤器生成哈希连接。

运行基准测试,我们得到如下结果:

Algorithm Median of 5
AsOf 0.425
IEJoin 3.522
State Join 192.460

AsOf 相对于 IEJoin 的运行时改进约为 9 倍。 Hash Join 糟糕的性能是由哈希表中的长(100K)桶链造成的。

第二个基准测试测试探针侧比构建侧小约 10 倍的情况:

CREATE TABLE probe AS
  SELECT k, 
    '2021-01-01T00:00:00'::TIMESTAMP + INTERVAL (random() * 60 * 60 * 24 * 365) SECOND AS t,
  FROM range(0, 100000) tbl(k);

CREATE TABLE build AS
  SELECT r % 100000 AS k, 
    '2021-01-01T00:00:00'::TIMESTAMP + INTERVAL (random() * 60 * 60 * 24 * 365) SECOND AS t,
    (random() * 100000)::INTEGER AS v
  FROM range(0, 1000000) tbl(r);

SELECT SUM(v)
FROM probe p
ASOF JOIN build b
  ON p.k = b.k
 AND p.t >= b.t

-- Hash Join Version
WITH state AS (
  SELECT k, 
    t AS begin, 
    v, 
    LEAD(t, 1, 'infinity'::TIMESTAMP) OVER (PARTITION BY k ORDER BY t) AS end
  FROM build
)
SELECT SUM(v)
FROM probe p INNER JOIN state s
  ON p.t >= s.begin AND p.t < s.end AND p.k = s.k
Algorithm Median of 5
State Join 0.065
AsOf 0.077
IEJoin 49.508

现在,AsOf 相对 IEJoin 的运行时改进是巨大的(约 500 倍),因为它可以利用分区来消除几乎所有的等式不匹配。

哈希连接实现在这里做得更好,因为优化器注意到探测端较小,并在“探测”表上构建哈希表。 此外,这里的探测值是唯一的,因此哈希表链是最小的。

排名窗口

使用窗口运算符的另一种方法是:

  • • 根据相等谓词连接表

  • • 过滤到构建时间早于探测时间的对

  • • 根据等式键和探测时间戳对结果进行分区

  • • 按构建时间戳降序对分区进行排序

  • • 过滤掉除排名 1 之外的所有值(即最大构建时间 <= 探测时间)

查询如下所示:

WITH win AS (
SELECT p.k, p.t, v,
    rank() OVER (PARTITION BY p.k, p.t ORDER BY b.t DESC) AS r
FROM probe p INNER JOIN build b
  ON p.k = b.k
 AND p.t >= b.t
QUALIFY r = 1
SELECT k, t, v
FROM win

此窗口查询的优点是它不需要哨兵值,因此它可以处理任何数据类型。 缺点是它会创建更多分区,因为它包含两个时间戳,这需要更复杂的排序。 此外,由于它在连接后应用窗口,因此可能会产生巨大的中间产物,从而导致外部排序和昂贵的内存不足操作。

对于此基准测试,我们将使用三个构建表和两个探测表,全部包含 10K 整数相等键。 探测表的每个键有 1 或 15 个时间戳:

CREATE TABLE probe15 AS
    SELECT k, purchase_timestamp
    FROM range(10000) cs(k), 
         range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 26 DAY) ts(t);
CREATE TABLE probe1 AS
    SELECT k, '2022-01-01'::TIMESTAMP + INTERVAL (customer_id) HOUR purchase_timestamp
    FROM range(10000) cs(k);

构建表要大得多,条目数大约是 15 个元素表的 10/100/1000 倍:

-- 10:1
CREATE TABLE build10 AS
    SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
    FROM range(10000) ks(k), 
         range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 59 HOUR) ts(t);
-- 100:1
CREATE TABLE build100 AS
    SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
    FROM range(10000) ks(k), 
         range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 350 MINUTE) ts(t);
-- 1000:1
CREATE TABLE build1000 AS
    SELECT k, t, (RANDOM() * 1000)::DECIMAL(7,2) AS v
    FROM range(10000) ks(k), 
         range('2022-01-01'::TIMESTAMP, '2023-01-01'::TIMESTAMP, INTERVAL 35 MINUTE) ts(t);

AsOf 连接查询是:

-- AsOf/IEJoin
SELECT p.k, p.t, v
FROM probe p ASOF JOIN build b
  ON p.k = b.k
 AND p.t >= b.t
ORDER BY 1, 2
-- Rank
WITH win AS (
SELECT p.k, p.t, v,
    rank() OVER (PARTITION BY p.k, p.t ORDER BY b.t DESC)  AS r
FROM probe p INNER JOIN build b
  ON p.k = b.k
 AND p.t >= b.t
QUALIFY r = 1
SELECT k, t, v
FROM win
ORDER BY 1, 2

结果如下所示:

(中位数为 5,排名/15/1000 除外)。

  • • 对于具有 15 个探针的所有比率,AsOf 是性能最好的。

  • • 对于具有 15 个探针的小比例,Rank 击败了 IEJoin(均带有窗口),但到了 100:1,它开始爆炸。

  • • 对于单元素探针,Rank 是最有效的,但即使如此,它在规模上相对于 AsOf 的优势也只有 50% 左右。

这表明 AsOf 可能会得到改进,但预测发生这种情况的位置会很棘手,而且出错会带来巨大的成本。

未来的工作

DuckDB 现在可以以合理的性能对所有不等式类型执行 AsOf 连接。 在某些情况下,即使使用我们的快速不等式连接运算符,性能增益也比标准 SQL 版本高出几个数量级。

虽然当前的 AsOf 运算符完全通用,但这里可以应用一些规划优化。

当存在选择性相等条件时,针对物化状态表进行过滤的哈希连接可能会明显更快。 如果我们能够检测到这一点并且有合适的哨兵值可用,则规划器可以选择使用散列连接而不是默认的 AsOf 实现。 还有一些用例,其中探测表比构建表小得多,并且具有相等条件,并且针对探测表执行哈希联接可以显着提高性能。 尽管如此,请记住 SQL 的优点之一是它是一种声明性语言: 指定想要的内容,然后将其留给数据库来确定如何进行。 现在我们已经定义了 AsOf 连接的语义,用户可以编写查询来说明这就是想要的 - 并且我们可以自由地不断改进方法!

DuckDB 工作中最有趣的部分之一是它扩展了无序数据的传统 SQL 模型。 DuckDB 可以轻松查询有序数据集(例如数据框和 parquet 文件),当拥有此类数据时希望能够进行有序分析! 实现快速排序、快速窗口化和快速 AsOf 连接是我们实现这一期望的方式。

Supongo que te gusta

Origin blog.csdn.net/Rocky006/article/details/132966148
Recomendado
Clasificación