SQL 中基于时间的记录分组

Time-based record grouping in SQL

我有一个用户交互数据库 table。我想根据交互的时间和地点创建用户组。也就是说,如果用户在大致相同的时间(例如 2 分钟 window)在同一位置进行交互,我会将他们视为一个组。这些组不需要相互排斥,但它们确实需要详尽无遗。每个用户交互都属于一个或多个组。

我过去用 python 和 做过类似的事情。但现在我仅限于 SQL 解决方案。

假设一个玩具数据table喜欢

create table example_intxns (
  intxn_date DATE, 
  loc_id number,
  intxn_timestamp timestamp,
  user_id varchar(100)
);

insert into example_intxns (intxn_date, loc_id, intxn_timestamp, user_id)
values
('2021-01-01', 1, '2021-01-01 08:00:00', 'a'),
('2021-01-01', 1, '2021-01-01 08:01:00', 'b'),
('2021-01-01', 1, '2021-01-01 08:02:00', 'c'),
('2021-01-01', 1, '2021-01-01 08:04:00', 'd'),
('2021-01-01', 1, '2021-01-01 08:05:00', 'e'),
('2021-01-01', 1, '2021-01-01 08:07:00', 'f'),
('2021-01-01', 1, '2021-01-01 08:10:00', 'g'),
('2021-01-01', 1, '2021-01-01 08:02:00', 'h')
;

我可以像这样创建一对在 2 分钟内互动的用户

select distinct 
    a.intxn_date, 
    a.loc_id,    
    a.user_id as seed_user_id, 
    b.user_id

from 
    example_intxns a
inner join 
    example_intxns b
        on a.intxn_date = b.intxn_date
        and a.loc_id = b.loc_id
        and timestampdiff(minute, a.intxn_timestamp, b.intxn_timestamp) between -2 and 2
        and b.user

此 returns 所有用户对的结果集基于相同的位置和 +/- 2 分钟的互动 window。用户将始终与自己配对——这对于没有其他人在同一时间和地点进行交互的情况很有用。一个用户可以与另一个用户配对。在这里,我使用 b.user_id >= a.user_id 条件,因为 A->B 等同于 B->A。我不需要两者。

但这就是我被困的地方。我不知道如何以 SQL 方式将对扩展到组。感觉可能是递归问题?

我想我想要的——而且我仍在考虑边缘情况——是一个看起来像

的结果集
Date Location GroupID GroupMember
2021-01-01 1 1 a
2021-01-01 1 1 b
2021-01-01 1 1 c
2021-01-01 1 1 h
2021-01-01 1 2 c
2021-01-01 1 2 h
2021-01-01 1 2 d
2021-01-01 1 3 d
2021-01-01 1 3 e
2021-01-01 1 4 e
2021-01-01 1 4 f
2021-01-01 1 5 g

这实际上告诉我,在这个日期,在这个位置,我有 5 个组:[a,b,c,h]、[c,h,d]、[d,e]、[e, f] 和 [g]

解决方案的复杂性包括同一用户在同一天在同一位置多次交互。玩具示例中不存在,但可能存在于实际数据中。

我在 Snowflake 工作,如果这有什么不同的话。现实世界的问题包括每天在六个地点的 10 万名用户,以及大约 5 年的互动。

编辑: 我将目标帖子在我想要的描述上移动了一点。我想要的输出反映了排除作为早期组子集的组出现的需要。也就是说,[b,c] 是一个有效组,但它包含在 [a,b,c,h] 组中。但我并没有特别指出这个标准。我的最终解决方案(包含此附加要求)如下:

with pairs as ( -- qualified pairs
select distinct 
    a.intxn_date, 
    a.loc_id,    
    a.user_id as seed_user_id,
    a.intxn_timestamp as seed_intxn_timestamp,
    b.user_id,
    b.intxn_timestamp as intxn_timestamp,
    dense_rank() over (partition by a.intxn_date, a.loc_id order by seed_user_id) as group_id

from 
    example_entries a
inner join 
    example_entries b
        on a.intxn_date = b.intxn_date
        and a.loc_id = b.loc_id
        and timestampdiff(second, a.intxn_timestamp, b.intxn_timestamp) between 0 and 120
        and b.user_id>=a.user_id
),
groups as ( -- qualified pairs converted into groups
select p.*,
    max(p.intxn_timestamp)over(partition by p.group_id) as max_group_intxn_timestamp
from pairs p
),
subtracts as ( -- groups already completely assumed in another, earlier gruop
select a.*
from groups a -- what we want to subtract
inner join groups b
  on a.seed_user_id = b.user_id
  and a.loc_id = b.loc_id
  and a.intxn_date = b.intxn_date
  and a.group_id > b.group_id
  and timestampdiff(second, a.seed_intxn_timestamp, b.intxn_timestamp) between 0 and 120
inner join groups c
  on b.loc_id = c.loc_id
  and b.intxn_date = c.intxn_date
  and b.seed_intxn_timestamp = c.seed_intxn_timestamp
  and b.group_id = c.group_id
  and c.user_id >= b.user_id
  and b.seed_user_id = c.seed_user_id
  and a.user_id = c.user_id
  and a.max_group_intxn_timestamp <= c.max_group_intxn_timestamp
)
select distinct a.intxn_date, a.loc_id, a.group_id, a.user_id, a.seed_intxn_timestamp as group_intxn_window_start_timestamp, a.max_group_intxn_timestamp as group_intxn_window_end_timestamp
from groups a
left join (select distinct intxn_date, loc_id, seed_intxn_timestamp, group_id from subtracts) b
on a.intxn_date = b.intxn_date
and a.loc_id = b.loc_id
and a.seed_intxn_timestamp = b.seed_intxn_timestamp
and a.group_id = b.group_id
where b.group_id is null
/*minus -- could have used a MINUS, but I think the LEFT JOIN way is safer because it removes all traces of the unqualified group
select distinct intxn_date, loc_id, group_id, user_id, seed_intxn_timestamp
from subtracts a*/
order by 1,2,3,4

我稍微修改了您的查询,然后使用 RANK():

获得了组 ID
select *, rank() over(partition by intxn_date order by seed_timestamp) grp_id
from (
    select a.intxn_date, a.loc_id, a.intxn_timestamp as seed_timestamp, b.user_id
    from  example_entries a
    inner join example_entries b
    on a.intxn_date = b.intxn_date
    and a.loc_id = b.loc_id
    and timestampdiff(minute, a.intxn_timestamp, b.intxn_timestamp) between 0 and 2
    and iff(a.intxn_timestamp=b.intxn_timestamp, a.user_id>=b.user_id, true)
)

组id不连续,但是可以看到有7个不同的组,每个组至少有一个元素。有些行属于多个组,但没有没有组的行。

你很接近。只需将此添加到您现有的 select 语句中,即可将组 ID 分配给用户交互。它产生 8 个组,但那是因为它允许组相互包容,这样用户可以每天多次与其他用户交叉(允许 +-2 分钟差异)。它还允许用户与自己交叉,而不管他们是否也与其他用户交叉。

既然你还在考虑边缘情况,我认为这是一个 good/flexible 的开始,应该会让你越过分组障碍。确保您的数据在 join

之前被删除了重复数据
dense_rank() over (partition by a.intxn_date, a.loc_id order by a.user_id) as group_id

另一种方法可能如下:

首先获取所有可能的位置|时间组合(又名 groupys)。

简单的左外连接。完成!

我还注意到 'correct' 回答中 8:02 的用户数应该是 3,而不是报告的 5。

代码|复制|粘贴|运行:

select 
  groupys.groups 
, groupys.loc_id
, example_intxns.user_id
, groupys.starts
 from
   ( select  
       row_number() over (order by intxn_timestamp) groups 
     , loc_id
     , intxn_timestamp  starts
     , intxn_timestamp + interval '2 minutes'  ends 
    from 
      example_intxns 
   group by 
      2,3,4)  groupys
left outer join 
    example_intxns 
 on example_intxns.loc_id = groupys.loc_id 
 and example_intxns.intxn_timestamp between groupys.starts and groupys.ends